diff --git a/changelog.d/added/763-area-bodies.md b/changelog.d/added/763-area-bodies.md new file mode 100644 index 000000000..5a2fa6da6 --- /dev/null +++ b/changelog.d/added/763-area-bodies.md @@ -0,0 +1,21 @@ +- **A Revit 2024 Area's `IfcSpace` has a body: the region its area boundary + lines enclose, from its Level up to the Level above it.** The Area stores no + boundary; rvt-rs takes the lines of its area scheme on its Level, splits + them where they meet or cross, leaves out the ones with a free end, and + draws the loop that holds the Area's point, with the loops directly inside it + as holes, as Revit's export draws it. On the BIM4LCA office all 30 Areas, as + built and at building permit, equal Revit's in area (within 0.1%) and in + outline (within 0.01 ft), where Measure scored none of them before. An Area + keeps no body, as before, and says why in + `RvtElementRecordGeometry.AreaBodyRefusal` and `exported.area_body_refusals`, + wherever its boundary is not proven: a boundary line that is not straight + along the plan axes, that was not read, or that is in a non-primary design + option, a point in no single region, two Areas that share a region, or a + Level that is not a building story. Not + measured: the top storey (no Level above), Levels that are not building + stories, a Level whose "Story Above" is not the next Level up (never read), + Areas bounded partly by walls, and releases other than 2024. + `SpaceOutlineSource` is `area_boundary` on a drawn Area and `none` on one that + is not; the room schedule gives it an area. An Area does not hold a room's + furniture. `Qto_SpaceBaseQuantities.Height`, which Revit's export writes on an + Area, is not written yet ([docs/rooms.md](docs/rooms.md), #763, RE-317). diff --git a/docs/evidence.md b/docs/evidence.md index e612d6a01..515228c72 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -76,7 +76,7 @@ On Revit 2024 and 2025 project files, rvt-rs reads each element from its partiti | Structural framing, columns, foundations | `IfcBeam` along its location line, `IfcColumn`, `IfcFooting` | Snowdon structural sample: all 1,078 in the category its VIM export gives them; 923 of 942 beams run along their line, with Revit's own section on 613 of the 839 a later edition kept unchanged, and on 179 more less the top a floor join cuts (RE-49); 377 steel beams and 40 steel columns are their type's I section, Revit's own on all 271 beams and 24 columns the VIM scores (RE-103, RE-104) | | Site elements, generic models, slab edges, elevators, ramps | proxies, `IfcTransportElement`, `IfcRamp` | Snowdon Towers, every one Revit exports | | Rooms | `IfcSpace` named as Revit's export names it, `Name` the room number (for a room with no number, `:`, RE-196) and `LongName` the room name, with the room's ElementId as the `ElementId` property and the outline of the solid Revit stores for the room (RE-101) | Core Interior all 116 and RE1 Architecture all 11 at Revit's own area and outline (RE-101's run of `tools/re/plan_profiles_vs_ifc.py --class IfcSpace`; neither CI nor Measure scores room outlines); Snowdon Towers 45 of 54 at the area its VIM export gives; on Revit 2023, Exemplo_data all 9 (RE-102); every room with Revit's number and name, Core Interior 116, Snowdon Towers 54 (against the VIM), RE1 11 and Exemplo_data 9 (RE-117), and the BIM4LCA office 264 of 264 as built and at building permit, 43 of them with no number (RE-196); a room whose solid the exact walk does not close is read again from the faces its solid lists, labelled `stored_solid_faces` or `stored_solid_approximate` in `SpaceOutlineSource` (the BIM4LCA office 13 of 13 rooms as built and 11 of 12 at permit; `stored_solid_faces` at Revit's area to 0.1% on 9 of 9, `stored_solid_approximate` on 10 of 15 room scores and 0.45% to 0.68% under Revit's floor area on the other 5, RE-316), and keeps its box otherwise | -| Areas | `IfcSpace .SPACE.` for each placed Area of a Revit 2024 file, named as Revit's export names it (`Name` the number, `LongName` the name), in its Level's storey and typed by its own `IfcSpaceType`; no body, since the Area's boundary is not decoded | the BIM4LCA office as built and at building permit: all 30 of Revit's area spaces, number, name and GlobalId as Revit's (RE-191) | +| Areas | `IfcSpace .SPACE.` for each placed Area of a Revit 2024 file, named as Revit's export names it (`Name` the number, `LongName` the name), in its Level's storey and typed by its own `IfcSpaceType`; the body is the region that the lines of its area scheme on its Level enclose around its point (its holes the loops directly inside), extruded from its Level to the Level above, when both are building stories, where that region is proven, and none where it is not (RE-317) | the BIM4LCA office as built and at building permit: all 30 of Revit's area spaces, number, name and GlobalId as Revit's (RE-191), and their bodies: 30 of 30 on each file within 0.1% of Revit's area and within 0.01 ft of its outline, and with the lowest and highest vertex of the body within 0.01 ft of Revit's (RE-317) | | Storeys | `IfcBuildingStorey` for each Revit Level that is a building story, with its name and elevation | Core Interior 15, Snowdon Towers 18, every RE1 model 2; name, elevation and GlobalId all Revit's (RE-24, RE-51); Snowdon Towers Structural 12 of its 19 Levels, its VIM export's building stories, 208 of the 210 elements on them in their storey (RE-118) | | Storey containment | each element in the storey of the Level its record names, of its base constraint where it names two, and, where it names none, of a railing's host, of the Level its work planes carry, or of its base elevation | Core Interior all 854 in Revit's storey; Snowdon Towers 5,949 in Revit's storey, 1 in another and 1 on none (RE-27, RE-59, RE-60, RE-68) | | Names | `Family:Type:ElementId`, `ObjectType` `Family:Type`, for family instances and, since RE-63 to RE-65, walls, curtain walls, floors, ceilings, roofs, railings, slab edges, ramps, curtain panels that are walls, and stairs with their runs, landings and supports | 4,397 family instances named exactly as Revit names them; Snowdon's walls (all 1,077 RE-63 matches by `Tag`), 20 roofs and 68 ceilings and Core Interior's 460 system-family elements all named as Revit names them (RE-63); Snowdon's 18 panel walls and 59 slab edges too (RE-64), its 256 stair elements (RE-65) and every stair, flight and stringer of the five R2025 stair models (RE-65 §6), all 131 of its railings (RE-66), its 7 model texts (RE-67) and its 258 wall sweeps (RE-69); the BIM4LCA office's 220 curtain walls, their type a curtain wall type's native name (RE-230) | diff --git a/docs/export-diagnostics.md b/docs/export-diagnostics.md index 2f2d2b011..fd81bab3b 100644 --- a/docs/export-diagnostics.md +++ b/docs/export-diagnostics.md @@ -203,6 +203,7 @@ Important nested fields: | `exported.storey_bound_elements` | integer | Building elements contained in a specific storey. The rest are contained in the `IfcBuilding`, never in a named storey. | | `exported.layered_element_count` | integer | Elements whose compound layers and layer colours were read (RE-53). The glTF export draws each in its layers where they add up to its body's thickness; the rest are drawn whole. | | `exported.bounding_box_spaces` | integer | Exported `IfcSpace`s whose body is their record's bounding box (`RvtElementRecordGeometry.SpaceOutlineSource` is `record_box`): a room whose outline was not read from its stored solid, or a family instance written as a space and drawn as its box. The body's plan area is the box's, not the space's (RE-313, [rooms](rooms.md)). Each is also in `exported.bounding_box_bodies`, except a family instance turned or tilted off the model's axes. | +| `exported.area_body_refusals` | object | Of the exported Areas that have no body, why, by reason, counted from each Area's `RvtElementRecordGeometry.AreaBodyRefusal` (RE-317): `levels_not_recovered`, `boundary_line_not_read` (a declared area boundary line was not read or cannot be placed on a Level, so no Area of the file is drawn), `no_boundary_lines`, `point_not_read`, `level_or_scheme_not_named`, `named_lines_not_in_scheme_and_level` (a line the Area's record names is not a declared line of its scheme on its Level that was read: its copies disagree, it has no Level or scheme, or it is in a non-primary design option), `next_storey_not_proven` (the Area's Level and the one above are not both building stories, or another Level lies between or at them), `boundary_lines_not_readable` (a line of the group is not straight along an axis, overlaps another, may be missing, or is in a non-primary design option), `point_in_no_single_region`, `named_lines_not_on_region` and `two_areas_one_region` (another placed Area of the same scheme and Level has the same region, whether or not that one is drawn). Empty when every Area is drawn or the export has no geometry. | | `exported.resolved_outline_spaces` | integer | Exported rooms whose outline was read from the faces their stored solid lists, up to the solid's own end, where the walk of RE-101 did not close them (`RvtElementRecordGeometry.SpaceOutlineSource` is `stored_solid_faces`, RE-316, [rooms](rooms.md)). Equal to Revit's area on every such room measured, but bounded by the solid's list of objects and a gap of 1,024 bytes, so a closed loop of another element's faces inside the room's box would be read as the room's. | | `exported.approximate_outline_spaces` | integer | Exported rooms whose resolved outline is the boundary of a region, a column that overlaps a wall having been taken off it or an end of one wall edge lying on another, or falls up to 0.02 ft short of their record box on a side (`SpaceOutlineSource` is `stored_solid_approximate`, RE-316, [rooms](rooms.md)). The area is approximate: up to 0.68% under Revit's floor area where Revit's export does not take that column off the room, and equal to it on 10 of the 15 BIM4LCA office room scores. | | `confidence.level` | string | `scaffold`, `typed_no_geometry`, `geometry`, `diagnostic_partial`, or `proxy_only`. | diff --git a/docs/format-reconnaissance.md b/docs/format-reconnaissance.md index f21e46a93..e874dad61 100644 --- a/docs/format-reconnaissance.md +++ b/docs/format-reconnaissance.md @@ -2407,3 +2407,7 @@ Discussion #112 warned that Revit computes feet as `mm / 25.4 / 12`, which can d ## Addendum — RE-311 pipe fittings and cone faces (2026-11-03) A saved pipe elbow or transition is 1.08 to 1.32 of Revit's volume because Revit's export of it is a polyhedron of 6 to 10 sides round the pipe and two segments along a 90° bend, inscribed in the surface the saved graphics describe: an elbow's true torus is `π r² R π/2`, rvt-rs's mesh is 98 to 99% of it and Revit's 74 to 84%. Tees and closet bends are 1.11 to 1.17, where Revit's triangulated junctions leave some of its vertices off the surface. The "wrong" volumes of the closet bends were the scorer's, which took them about the file's origin though a mesh with cracks between faces changes with its distance from it. A `ConeSurf` is `apex + v (cos α · z + sin α (cos u · x + sin u · y))` with `v` along the generator (the radius at `v` is `v sin α`), which draws 8 more RE1 Plumbing transitions. `GNurbSpline` and `GHermiteSpline` profile curves were read too and gain no body Revit's export holds: the two RE1 Electrical panels keep their box for trims that RE-302 and RE-305 measured. Report: `reports/element-framing/RE-311-fitting-faceting-cones-splines.md`. + +## Addendum — RE-317 where an Area's boundary is (2026-11-03) + +A Revit 2024 Area stores no boundary: its chain record (RE-35) names its Level, its area scheme (`OST_AreaSchemes`, -2003201) and two elements of `OST_AreaSchemeLines` (-2000079), area boundary lines, each a chain record with a bounding box of its own that is the line itself where the line runs along an axis (all 689 of the BIM4LCA office's do). Two `f64`s at byte 107 of the payload of the Area's data object (class 0x0e1c) are a point in plan feet inside the Area. Revit's export draws the region of the Area's scheme's lines on its Level that holds the point, less the loops directly inside it, extruded from the Level to the Level above, when both are building stories: all 30 Areas of the office, as built and at building permit, equal Revit's in area to 0.1% and in outline to 0.01 ft. The report is `reports/element-framing/RE-317-area-boundaries.md`; the probe is `probes/examples/probe_re317_area_boundaries.rs`. diff --git a/docs/rooms.md b/docs/rooms.md index a859aed11..ed7c7e1ee 100644 --- a/docs/rooms.md +++ b/docs/rooms.md @@ -1,13 +1,13 @@ # Rooms and spaces in an rvt-rs IFC export -This page is for the importer of an rvt-rs export: what an `IfcSpace` holds for each release, the one property that says whether to trust its area, what is measured against Revit's own export and where, and what is not. Every `IfcSpace` rvt-rs writes in a geometry export (the default) carries that property: rooms, Revit Spaces, family instances written as spaces and Areas alike. An export with no geometry (`typed_no_geometry`) has no outlines and no property. The evidence is in [RE-313](../reports/element-framing/RE-313-space-outlines-measured.md) and, for the rooms whose stored solid the first reader did not close, [RE-316](../reports/element-framing/RE-316-room-solids-resolved.md); the earlier findings are RE-29, RE-101, RE-102, RE-117, RE-153, RE-196, RE-255 and RE-296. +This page is for the importer of an rvt-rs export: what an `IfcSpace` holds for each release, the one property that says whether to trust its area, what is measured against Revit's own export and where, and what is not. Every `IfcSpace` rvt-rs writes in a geometry export (the default) carries that property: rooms, Revit Spaces, family instances written as spaces and Areas alike. An export with no geometry (`typed_no_geometry`) has no outlines and no property. The evidence is in [RE-313](../reports/element-framing/RE-313-space-outlines-measured.md) and, for the rooms whose stored solid the first reader did not close, [RE-316](../reports/element-framing/RE-316-room-solids-resolved.md); the earlier findings are RE-29, RE-101, RE-102, RE-117, RE-153, RE-196, RE-255 and RE-296, and RE-191 and [RE-317](../reports/element-framing/RE-317-area-boundaries.md) for Areas. ## Which `IfcSpace`s rvt-rs writes | Source | `IfcSpace` | Body | |---|---|---| | A Revit room | `Name` is the room's number, `LongName` its name, `PredefinedType` `SPACE`, in the storey of the Level its record names | the outline of the room's stored solid, or the record's bounding box (below) | -| A Revit area (Revit 2024) | the same naming | none: the space has no `Representation` | +| A Revit area (Revit 2024) | the same naming | the region its area boundary lines enclose around its point, up to the Level above it (below); none where that is not proven: the space then has no `Representation` | | A Revit Space (MEP), in a model that has one | `IfcSpace` without a predefined type; no measurement singles one out | its record's box (`record_box`) unless a carrier draws more (`family_body`) | | A family instance whose "Export to IFC As" names `IfcSpace` (#763) | named `Family:Type:ElementId` as Revit's export names other family instances | its record's box, turned with the instance (`record_box`), until a carrier draws its family. Where its family draws no model form, Revit's export leaves the instance out, and rvt-rs will too once PR #1279 (RE-312, for #1262, parking bays) lands | @@ -24,7 +24,8 @@ Read `RvtElementRecordGeometry.SpaceOutlineSource` on the `IfcSpace`: | `stored_solid_approximate` | the same, as the boundary of a region, a column that overlaps a wall taken off it or an end of one wall edge lying on another, or up to 0.02 ft short of the room's record box on a side | approximate. Of the office's 15 room scores (4 as built, 11 at permit) 10 are within 0.1% of Revit's floor area and 5 are 0.45% to 0.68% under it (four rooms, 1671995 in both files): Revit's export does not take such a column off the room, and nothing in the file says which. The room schedule leaves the area blank (RE-316 §2, §3) | | `record_box` | the record's bounding box (turned with a family instance that is turned) | the box's area, not the space's. It equals the space's only when the space is a rectangle; the area is Revit's on none of the office's room scores that keep their box (1 room at permit, 3095831; RE-316 §4) | | `saved_mesh` | the mesh Revit saved for the room (`rvt-ifc --saved-meshes`) | the mesh's; not scored | -| `none` | no body (an Area) | not available | +| `area_boundary` | the region that the lines of the Area's scheme on its Level enclose around its point, its holes the loops directly inside, extruded from its Level up to the Level above it, both building stories | Revit's area space: within 0.1% of Revit's export, within 0.01 ft in outline, and with its lowest and highest vertex within 0.01 ft, on 60 of the 60 Area scores below, the office counted at both stages (RE-317 §5) | +| `none` | no body (an Area whose boundary is not proven) | not available | | `family_body` | a Revit Space or a family instance written as an `IfcSpace`, drawn as more than its box; its `BodySource` says how | not scored | A room's `BodySource` is `partition_element_record_bbox` whichever of the stored-solid or `record_box` values it has, because the placement and the height always come from the record's box: read `SpaceOutlineSource`, not `BodySource`. An export from before this property (rvt-rs 0.12.x and earlier) says the same through `ProfileSource` (`partition_room_solid_faces`, and since RE-316 `partition_room_solid_faces_resolved` and `partition_room_solid_faces_approximate`) and `ProfileResolved`. A room with `stored_solid_faces` or `stored_solid_approximate` carries no `NetVolume`. @@ -42,7 +43,7 @@ The export also counts the spaces drawn as their record's box (rooms and family | 2008 to 2021 | read from the stored solid where the reader finds one | held-out models (below): two 2021 projects; Vitoria's Revit 2018 `ALD26 Before` (spent, below), whose frames sit in their records (RE-315, #1261) | | 2022 | stored solid | 8 held-out rooms | | 2023 | stored solid | 9 of 9 rooms of one project, local only (RE-102) | -| 2024 | stored solid; Areas as spaces without a body | Core Interior, the BIM4LCA office, held-out models (below) | +| 2024 | stored solid; Areas as spaces with a body where their boundary lines prove one | Core Interior, the BIM4LCA office, held-out models (below) | | 2025 | stored solid | RE1 Architecture, six purpose-made R2025 models, held-out models (below) | | 2026, 2027 | stored solid, as on 2025 (RE-314, #1260) | 16 held-out rooms, all rectangles, and a held-out house (below) | @@ -50,11 +51,12 @@ The export also counts the spaces drawn as their record's box (rooms and family ## What is measured, and where -`tools/re/space_profiles_vs_ifc.py` meshes both exports, moves them into Revit's internal feet and compares the plan union of each space's upward faces. It prints three figures, each on its own line: +`tools/re/space_profiles_vs_ifc.py` meshes both exports, moves them into Revit's internal feet and compares the plan union of each space's upward faces. It prints four figures, each on its own line: - **area**: the plan area, within 0.1% of Revit's; - **boundary**: the largest distance from a point of either outline to the other's outline, within 0.01 ft. This is the room's outline, and the figure used for "outline" in the table and in the text below; - **outline**: the largest distance from a vertex of either mesh's upward faces to the other's outline, within 0.01 ft. Revit meshes a ceiling step inside a room as an upward face of its own, and its vertices count against this one, so it reads some correct outlines as wrong (RE-313 §3). The CI gate pins both. These three read the faces of Revit's mesh that point up, within 25 degrees of vertical, so a room under a stepped or sloped ceiling is read as the flat part of it: `tools/re/space_overlaps_vs_ifc.py` also measures the plan area of each space's floor (its downward faces) and of every face projected, in both exports, and counts the rooms within 0.1% of Revit's by each (RE-316 §3). The floor is the room's plan. +- **height**: the lowest and the highest vertex of either mesh, within 0.01 ft: the base and the top of the space, so its height. It is printed on its own line and for each kind of space (RE-317); it is 306 of 306 spaces in both exports on the office as built and 308 of 308 at building permit. Measure runs the scorer on every reference model (`-f scorers=space_profiles_vs_ifc`); the IfcOpenShell job of CI runs it on the first two rows, pairing by GlobalId (`--match globalid`), and fails below their counts in area, outline and boundary. @@ -62,8 +64,8 @@ Measure runs the scorer on every reference model (`-f scorers=space_profiles_vs_ |---|---:|---:|---:|---:|---| | Core Interior (2024) | 116 | 116 | 116 | 116 | 116 rooms, stored solid: 116, 116 | | RE1 Architecture (2025) | 11 | 11 | 11 | 11 | 11 rooms, stored solid: 11, 11 | -| BIM4LCA office as built (2024) | 276 of Revit's 308 | 230 | 230 | 220 | 220 rooms, stored solid: 220, 220; 9 rooms, stored solid faces: 9, 9; 3 rooms, stored solid approximate: 0, 0 (4 by Revit's floor, 0 within 0.1%); 44 family instances, record box: 1, 1 | -| BIM4LCA office at building permit (2024) | 278 of Revit's 308 | 232 | 232 | 225 | 222 rooms, stored solid: 221, 221; 11 rooms, stored solid approximate: 10, 10; 1 room, record box: 0, 0; 44 family instances, record box: 1, 1 | +| BIM4LCA office as built (2024) | 306 of Revit's 308 | 260 | 260 | 250 | 220 rooms, stored solid: 220, 220; 9 rooms, stored solid faces: 9, 9; 3 rooms, stored solid approximate: 0, 0 (4 by Revit's floor, 0 within 0.1%); 44 family instances, record box: 1, 1; 30 areas, area boundary: 30, 30 | +| BIM4LCA office at building permit (2024) | 308 of Revit's 308 | 262 | 262 | 255 | 222 rooms, stored solid: 221, 221; 11 rooms, stored solid approximate: 10, 10; 1 room, record box: 0, 0; 44 family instances, record box: 1, 1; 30 areas, area boundary: 30, 30 | | R2025 purpose-made models with rooms (2025), 6 models | 12 | 12 | 12 | 12 | 12 rooms, stored solid: 12, 12 | | Held-out models (2022, 2024, 2025), 6 models | 24 | 24 | 24 | 24 | 24 rooms, stored solid: 24, 24 | | Held-out models (2026, 2027), 4 models | 16 | 16 | 16 | 16 | 16 rooms, stored solid: 16, 16 (all rectangles) | @@ -76,18 +78,28 @@ On Warmond, RE-316 closes 6 of the 8 rooms that kept their box (5 were off in ar A model with no rooms in Revit's export (the other R2025 models, RE1 Electrical, Mechanical and Plumbing) scores 0 of 0. One held-out model writes none of Revit's spaces: BIM Whale's 2021 project (8), whose spaces are Areas, which rvt-rs reads on Revit 2024 only. Vitoria's 2018 project exported no element, and none of its 73 spaces, before PR #1286 (#1261): its spent `ALD26 Before` now writes all 73 (run 41707934595, full output, in the row above), and its held-out sibling `ALD26 Project` writes 73 `IfcSpace` against Revit's 73 (counts only, not scored here). -The other held-out figures are Measure run 41728355227 (`main` at 7fd339e2), counts only. On the 2026 house and on Gdynia, every room's area is right where it is scored, but none of their outlines is within 0.01 ft of Revit's; the files are held out, so why is not examined. +The other held-out figures are Measure run 41728355227 (`main` at 7fd339e2), counts only; the office's Area figures are Measure run 41740909229 (RE-317), which leaves every held-out count as it was. On the 2026 house and on Gdynia, every room's area is right where it is scored, but none of their outlines is within 0.01 ft of Revit's; the files are held out, so why is not examined. What the misses are: - **Rooms the exact walk did not close** (13 as built, 12 at permit; RE-316): as built 9 are `stored_solid_faces` and 4 `stored_solid_approximate`; at permit 11 are `stored_solid_approximate` and 1, 3095831, keeps its box (14 ends down a 100 ft wall are not joined). Before RE-316 all 13 and 12 kept their box (RE-316 §1, §3, §4). - **Room 1671995 (both files)** is `stored_solid_approximate`, 0.45% under Revit's floor area (5154.12 against 5177.37 ft² as built). The scorer's upward faces read it as 323% over (350% at permit): Revit's ceiling over it is mostly not flat, and its flat patches total 1218.33 ft² in 9 pieces (RE-316 §3). - **Family instances written as spaces (44 on the office)** are 42 "Turning Free Space" discs and two "VOLUME" families that Revit's export draws from the family's geometry (a mapped representation); rvt-rs draws their record box, a square where the family is a circle (24.22 ft² against 19.01 ft² on the 1500 mm turning spaces listed). No issue covers these yet; #1262 and PR #1279 are about the terrain office's parking bays, a different set, though its rule (a family instance written as a space is left out where its family draws no model form) may change this one too. -- **The office's other 30 spaces are its Areas**: Revit's export gives them a body, rvt-rs writes them without one (RE-191). +- **The office's 30 Areas** are drawn from their area boundary lines, and each equals Revit's in area and outline on both files (RE-317). An Area whose lines are not all along the plan axes, or whose point lies in no single region the lines bound, has no body. - **The permit office's STAIRCASE room 2611271**, 66% off in area by the scorer's upward faces (172.77 against 103.90 ft²), is within 0.1% of Revit's floor area, like the other 221 `stored_solid` rooms: the scorer reads a room with a stepped or sloped ceiling as a fraction of itself (RE-316 §3, `tools/re/space_overlaps_vs_ifc.py`). ## What is not measured +- **Areas** (RE-317), measured on the BIM4LCA office only: + - releases other than Revit 2024; + - the top storey: an Area on the highest Level has no body, where Revit draws it one unit high; + - Levels that are not building stories: an Area has a body only where its Level and the Level above are both read as building stories and no other Level lies between them or at either elevation. All ten of the office's Levels are storeys, so every Area there meets that, and a Level that breaks it is unmeasured; + - the Level's "Story Above", which Revit's exporter takes first for an Area's height and rvt-rs never reads: the office's heights equal the distance to the next Level up, so a Level whose Story Above is another Level is unmeasured; + - an Area's base offset, read as zero; + - Areas bounded partly by walls: the Area's point then lies in no region of its boundary lines (4 of the 7 Areas on one Level of Autodesk's `rac_advanced`, not examined further), and the Area keeps no body; + - boundary lines that are not straight along the plan axes (a slanted line or an arc): the group is not read, and the office has none; + - `Qto_SpaceBaseQuantities.Height`, which Revit writes on an Area and rvt-rs does not yet. + The diagnostics count the Areas without a body by reason (`exported.area_body_refusals`). - Revit 2008 to 2020 and 2023 on any model Measure can run, beyond the held-out counts above: no reference model with a Revit room export exists for them in the corpus. - Revit Spaces (MEP): no measurement singles one out. - Room outlines of Revit 2026 and 2027 beyond the rectangles: on the held-out house none is within 0.01 ft of Revit's (#1260). diff --git a/docs/schemas/export-diagnostics.schema.json b/docs/schemas/export-diagnostics.schema.json index 199260c6e..d689889b2 100644 --- a/docs/schemas/export-diagnostics.schema.json +++ b/docs/schemas/export-diagnostics.schema.json @@ -285,6 +285,14 @@ "minimum": 0, "description": "Exported IfcSpaces drawn as their record's bounding box (SpaceOutlineSource record_box): a room whose outline was not read from its stored solid, or a family instance written as a space and drawn as its box. Their body's plan area is the box's, not the space's (RE-313). Each is also in bounding_box_bodies, except a family instance turned or tilted off the model's axes." }, + "area_body_refusals": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "description": "Of the exported Areas that have no body, why, by reason (RvtElementRecordGeometry.AreaBodyRefusal, RE-317): levels_not_recovered, boundary_line_not_read, no_boundary_lines, point_not_read, level_or_scheme_not_named, named_lines_not_in_scheme_and_level, next_storey_not_proven, boundary_lines_not_readable, point_in_no_single_region, named_lines_not_on_region, two_areas_one_region. Empty when every Area is drawn or the export has no geometry." + }, "resolved_outline_spaces": { "type": "integer", "minimum": 0, diff --git a/docs/status.md b/docs/status.md index 372c36a98..f376bc7ec 100644 --- a/docs/status.md +++ b/docs/status.md @@ -113,7 +113,7 @@ walls Revit steps over a floor that covers them in part (29 of the BIM4LCA offic the rooms whose stored solid still does not close (RE-316 §4) and opening profiles are not decoded, and rvt-rs does not edit Revit model data. -**Rooms.** Each `IfcSpace` says in `RvtElementRecordGeometry.SpaceOutlineSource` whether its outline was read from the room's stored solid (`stored_solid`), is the record's bounding box (`record_box`), was drawn from a saved mesh or a family's body, or has no body (an Area), and the diagnostics count the boxes (`exported.bounding_box_spaces`). Measure scores every space's plan area and outline against Revit's export, and CI gates Core Interior (116 of 116 rooms) and RE1 Architecture (11 of 11). A room whose stored solid the exact walk did not close is read again from the faces its solid lists, up to the solid's own end, and says so: `stored_solid_faces` where the faces' edges close as the walk reads them (9 of the BIM4LCA office's 13 such rooms as built, all at Revit's area to 0.1%) or `stored_solid_approximate` where a column overlaps a wall, an end of one wall edge lies on another, or the outline falls up to 0.02 ft short of the box (4 as built and 11 of the 12 at building permit: 10 of the 15 at Revit's floor area to 0.1%, the 5 others, four rooms, 0.45% to 0.68% under it); the diagnostics count them (`exported.resolved_outline_spaces`, `exported.approximate_outline_spaces`). The scorer's upward-face area reads one of them (1671995) as 323% over and RE-313's 66% room as 66% off; by Revit's floor both are within 0.5% and 0.1%. The 6 `rac_advanced` rooms with round columns, 49 of the 50 `rme` rooms, Snowdon Towers' 7 and office room 3095831 at permit keep their box (RE-316). [docs/rooms.md](rooms.md) says what to trust per release, and RE-313 has the runs. +**Rooms.** Each `IfcSpace` says in `RvtElementRecordGeometry.SpaceOutlineSource` whether its outline was read from the room's stored solid (`stored_solid`), is the record's bounding box (`record_box`), was drawn from a saved mesh or a family's body, is an Area's region among its boundary lines (`area_boundary`, RE-317), or has no body (an Area whose boundary is not proven), and the diagnostics count the boxes (`exported.bounding_box_spaces`) and, by reason, the Areas that have no body (`exported.area_body_refusals`, RE-317). Measure scores every space's plan area and outline against Revit's export, and CI gates Core Interior (116 of 116 rooms) and RE1 Architecture (11 of 11). A room whose stored solid the exact walk did not close is read again from the faces its solid lists, up to the solid's own end, and says so: `stored_solid_faces` where the faces' edges close as the walk reads them (9 of the BIM4LCA office's 13 such rooms as built, all at Revit's area to 0.1%) or `stored_solid_approximate` where a column overlaps a wall, an end of one wall edge lies on another, or the outline falls up to 0.02 ft short of the box (4 as built and 11 of the 12 at building permit: 10 of the 15 at Revit's floor area to 0.1%, the 5 others, four rooms, 0.45% to 0.68% under it); the diagnostics count them (`exported.resolved_outline_spaces`, `exported.approximate_outline_spaces`). The scorer's upward-face area reads one of them (1671995) as 323% over and RE-313's 66% room as 66% off; by Revit's floor both are within 0.5% and 0.1%. The 6 `rac_advanced` rooms with round columns, 49 of the 50 `rme` rooms, Snowdon Towers' 7 and office room 3095831 at permit keep their box (RE-316). [docs/rooms.md](rooms.md) says what to trust per release, and RE-313 has the runs. ## Finding history @@ -357,7 +357,8 @@ export, and the storey it maps to is the one that export aggregates the room into, 116 of 116. On the BIM4LCA office, the 30 Areas carry no record box. They are read from their partition chain records and aggregated into their Levels' storeys as Revit's export aggregates them, 30 of 30 on each -file (RE-191). The other 853 bindings stay **NOT MEASURED** +file (RE-191), and drawn as the region their area boundary lines enclose +(RE-317). The other 853 bindings stay **NOT MEASURED** against Revit's own `IfcRelContainedInSpatialStructure`. The #213 column-derived path (`STOREY_ELEVATION_SOURCE_TYPES` = `IFCCOLUMN`) survives as the fallback for files with no recoverable Level diff --git a/docs/support-matrix.json b/docs/support-matrix.json index 5ee68b9b0..446617301 100644 --- a/docs/support-matrix.json +++ b/docs/support-matrix.json @@ -571,9 +571,9 @@ "area": "elements", "name": "Revit 2024 Areas from their partition chain records, as IfcSpace", "status": "experimental", - "evidence": "partition_area_records::areas_from_partition_chain_records (OST_Areas chain records, RE-21 instance words, the one recovered Level each record names, RE-117 number and name entries, the latest partition's copy, RE-40 design options); ifc::export_content area_property_set; category_map Area -> IFCSPACE .SPACE.; probes/examples/probe_re191_area_records.rs and tools/re/area_spaces_vs_ifc.py; tools/re/room_names_vs_ifc.py (number, name and storey of each space by GlobalId); reports/element-framing/RE-191-areas.md", - "notes": "An Area's chain record carries OST_Areas at +0x12 and no bounding-box marker at +0x50, so the element-record scan never reads it. Its container slot at +0x32 is unset and its placement kind at +0x42 is the placed-instance kind on every Area Revit exports; the record then lists the Area's Level among its ids. Measured on the BIM4LCA office (CC BY-SA 4.0) against Revit's IFC export: as built, 30 Areas and all 30 of Revit's area spaces, number, name and storey equal on 30 of 30; at building permit, 30 placed of 32, the 30 Revit exports, the 2 others carrying 0xffff8020 and not in Revit's export. Revit writes each as IfcSpace .SPACE. with an IfcSpaceType of its own, Pset_SpaceCommon.Reference and Pset_SpaceCommon.IsExternal false, which rvt-rs writes (IsExternal since RE-193); its body, Pset_SpaceHeaterTypeCommon.Reference and Qto_SpaceBaseQuantities.Height are not read. Revit 2024 only.", - "user_impact": "Gross, net and office areas a Revit 2024 model defines come out as IfcSpace with Revit's GlobalIds, numbers, names and storeys, so an export consumer sees the same area spaces as Revit's export lists; they carry no geometry." + "evidence": "partition_area_records::areas_from_partition_chain_records (OST_Areas chain records, RE-21 instance words, the one recovered Level each record names, RE-117 number and name entries, the latest partition's copy, RE-40 design options); ifc::export_content area_property_set; category_map Area -> IFCSPACE .SPACE.; probes/examples/probe_re191_area_records.rs and tools/re/area_spaces_vs_ifc.py; tools/re/room_names_vs_ifc.py (number, name and storey of each space by GlobalId); reports/element-framing/RE-191-areas.md; partition_area_boundaries::Arrangement::face_at (the region of an Area's scheme lines on its Level around its point), partition_area_records::area_bodies (AREA_POINT_OFFSET, the Level above, the declared boundary lines all read), ifc::export_content::area_body_from_decoded (BodySource partition_area_boundary_lines, SpaceOutlineSource area_boundary), probes/examples/probe_re317_area_boundaries.rs, tools/re/area_boundaries_vs_ifc.py, tools/re/space_profiles_vs_ifc.py, reports/element-framing/RE-317-area-boundaries.md", + "notes": "An Area's chain record carries OST_Areas at +0x12 and no bounding-box marker at +0x50, so the element-record scan never reads it. Its container slot at +0x32 is unset and its placement kind at +0x42 is the placed-instance kind on every Area Revit exports; the record then lists the Area's Level among its ids. Measured on the BIM4LCA office (CC BY-SA 4.0) against Revit's IFC export: as built, 30 Areas and all 30 of Revit's area spaces, number, name and storey equal on 30 of 30; at building permit, 30 placed of 32, the 30 Revit exports, the 2 others carrying 0xffff8020 and not in Revit's export. Revit writes each as IfcSpace .SPACE. with an IfcSpaceType of its own, Pset_SpaceCommon.Reference and Pset_SpaceCommon.IsExternal false, which rvt-rs writes (IsExternal since RE-193); Pset_SpaceHeaterTypeCommon.Reference and Qto_SpaceBaseQuantities.Height are not read. Its body (RE-317) is the region that the lines of its area scheme on its Level enclose around its point (two f64s in its data object), its holes the loops directly inside, extruded from its Level up to the Level above it, where both are building stories and no other Level lies between them (the Level's story above is not read): on both office files all 30 Areas are within 0.1% of Revit's area and 0.01 ft of its outline, and an Area whose lines are not all straight along the plan axes, or whose point lies in no single region they bound, keeps no body, and says why (AreaBodyRefusal, exported.area_body_refusals). Revit 2024 only.", + "user_impact": "Gross, net and office areas a Revit 2024 model defines come out as IfcSpace with Revit's GlobalIds, numbers, names and storeys, so an export consumer sees the same area spaces as Revit's export lists; their bodies are the area outlines Revit draws, where the Area's boundary lines prove one, and absent where they do not." }, { "id": "record-level-storeys", diff --git a/docs/supported-profile.md b/docs/supported-profile.md index 87f3fe2ab..5ec3ef499 100644 --- a/docs/supported-profile.md +++ b/docs/supported-profile.md @@ -113,7 +113,7 @@ counts reported on 2026-11-02 and 2026-11-03 on 0.12.0): | Curtain walls | aggregates of panels and mullions placed by their grids; a mullion or panel turned or tilted off the model's axes as the box along its own axes (RE-106) | every Snowdon panel and mullion relation Revit's; 1,019 turned or tilted mullions and panels with Revit's extents along their axes | | Doors, windows, openings | the element's box, turned with the instance where it is turned (RE-87); its opening is as deep as the host wall | host is Revit's on 138 of 138 (Core Interior) and 192 of 194 (Snowdon; the other 2 in another wall Revit cuts, RE-85), and on Revit 2024 and 2025 the wall its native record names, Revit's on all 463 of the BIM4LCA office's as built (RE-245); on Revit 2024 and 2025 the opening is the outline the symbol that draws it cuts, through the instance's transform: 138 of 138 on Core Interior and 4 of 4 on RE1 within 0.001 ft of Revit's (RE-215, #227; Measure run 40927543524); a window whose type stores Width, Height and Default Sill Height takes Revit's opening exactly (56 of 68 on Snowdon, RE-93), and a door whose body is its type's Rough Height tall its rough opening (57 of 126, RE-94); any other door whose type stores Width and Height carries them as its overall size (137 of 137 on Core Interior and RE1, RE-187; Measure run 40927543524); on Revit 2024 and 2025 a door's or window's overall size is the outline the symbol that draws it cuts, Revit's on every one with such an outline (138 of 138 on Core Interior, 4 of 5 on RE1, RE-215); a door or window whose symbol cuts no outline takes its own extents as its overall size, Revit's on all 269 windows and 266 of 269 doors of the BIM4LCA office (RE-218); otherwise not Revit's opening profile (#227, RE-83) | | Rooms | `IfcSpace` with number, name and the outline of the room's stored solid (RE-101), or the record's bounding box, which the space's `SpaceOutlineSource` says; [docs/rooms.md](rooms.md) | Revit's own area and outline on all 116 Core Interior and 11 RE1 Architecture rooms, scored by Measure's `space_profiles_vs_ifc` and gated in CI (RE-313); all 220 of the BIM4LCA office's scored rooms read from their stored solid by the exact walk as built (221 of 222 at building permit) at Revit's area and outline; its rooms the walk did not close take a resolved outline, `stored_solid_faces` (9 of 9 as built at Revit's area) or `stored_solid_approximate` (10 of 15 room scores at Revit's floor area, the 5 others 0.45% to 0.68% under it), and its 1 room (permit) that keeps its box and 44 family-instance spaces drawn as boxes almost never are Revit's (RE-313 §3, RE-316); 45 of 54 Snowdon Towers rooms at the area its VIM export gives, the other 7 whose solids do not close keeping their box (#90); on Revit 2023, all 9 of Exemplo_data's (RE-102) | -| Areas | `IfcSpace` with number, name and storey; no body | all 30 of Revit's area spaces on the BIM4LCA office, as built and at building permit (RE-191); Revit 2024 only | +| Areas | `IfcSpace` with number, name and storey, and the region its area boundary lines enclose around its point, up to the Level above it, where its boundary lines and both Levels are proven (`SpaceOutlineSource` `area_boundary`; none otherwise) | all 30 of Revit's area spaces on the BIM4LCA office, as built and at building permit (RE-191), and their bodies, 30 of 30 on each file within 0.1% in area and 0.01 ft in outline, base and top (RE-317); Revit 2024 only | | Furniture, fixtures, equipment, MEP devices, site | the element's bounding box, turned with the instance where it is turned (RE-87) | placement and extent only; no family geometry. MEP devices and equipment are measured on RE1 Electrical and Mechanical only (RE-79) | | Family instances from their saved meshes (doors and windows by default on Revit 2023 to 2027, every other family with `--saved-meshes`) | the mesh Revit saved for the element's display | within 0.01 ft of Revit's export: Core Interior 158 of 158, RE1 (2025) 207 of 214, Snowdon Towers 3,332 of 3,736. Revit's own shape, every vertex within 0.001 ft: Core Interior 138 of 138 doors and windows, the BIM4LCA office 1,294 of 1,709 scored (all its windows; 48 of 269 doors), RE1 148 of 214; a mesh that is not exact is part of the body (RE-78). Since RE-224 a mesh some face of which did not decode keeps its box: on the office 1,294 of the 1,352 bodies then drawn are exact (1,376 of 1,438 at permit) | diff --git a/docs/user-guide.md b/docs/user-guide.md index 4f3a6f14f..d06a041e2 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -218,9 +218,10 @@ ElementId, IFC type, level and its elevation, material, placement, body size, the host wall of each door and window, and where the body came from (`body_source`, `profile_resolved`). A value rvt-rs did not decode is an empty cell, never a guess. The room schedule gives a room's plan area -where its outline was read from the solid Revit stores for it (RE-101); a -room that keeps its bounding box has an empty area, since the area of a box -is not the area of a room. +where its outline was read from the solid Revit stores for it (RE-101), and +an Area's where its boundary lines gave it a region (RE-317); a room that +keeps its bounding box has an empty area, since the area of a box is not the +area of a room. The columns, in order. Lengths are in feet; with `--metric` they are in metres and `_ft` in a column's name becomes `_m`. A later release may add diff --git a/probes/examples/probe_re317_area_boundaries.rs b/probes/examples/probe_re317_area_boundaries.rs new file mode 100644 index 000000000..a2b48d547 --- /dev/null +++ b/probes/examples/probe_re317_area_boundaries.rs @@ -0,0 +1,342 @@ +//! RE-317: where a Revit 2024 project keeps an Area's boundary. +//! +//! FACT under test: Revit's export draws each Area as an +//! `IfcExtrudedAreaSolid` of the Area's boundary, from its Level to the next +//! storey (RE-191 §3). The Area stores no boundary. Its chain record (RE-35) +//! names its Level, its area scheme (`OST_AreaSchemes`, -2003201) and two +//! elements of `OST_AreaSchemeLines` (-2000079), area boundary lines, each a +//! chain record with a bounding box of its own: for a straight line along an +//! axis, the line itself. The two `f64`s at byte 107 of the payload of the +//! Area's data object (RE-153) are a point of plan feet inside the Area. The +//! Area's region is the one of the arrangement of its scheme's lines on its +//! Level that holds the point, less the loops directly inside it. Verify with +//! `tools/re/area_boundaries_vs_ifc.py `, which +//! prints Revit's profile of each Area, its depth against the storeys, whether +//! the point lies in it, and the lines on its outline. +//! +//! This probe prints, tab-separated: +//! +//! - `level`: every recovered Level, with its elevation and its Building Story +//! setting (`Some(true)`, `Some(false)` or `None` where it is not read); +//! - `cat`: a census of the chain records' categories; +//! - `area`: every Area's ElementId, GlobalId, number, name, the one Level its +//! record names (`-` for none or several, and ids below 1000 are not read +//! here), whether its placement word is the placed-instance kind, and the ids +//! its record names with their categories; +//! - `rec`, `obj`: the bytes of its chain record and of each data object it +//! owns (RE-153); +//! - `occ`: where else its ElementId appears as a `u64`, with the chain record +//! that holds it; +//! - `solid`: the stored solids (RE-101's layout) keyed by an Area or by an +//! element its record names; +//! - `chain`: the bytes of every chain record an Area names, and of a sample of +//! the area boundary lines and polylines; +//! - `line`: every area boundary line and area polyline whose element record +//! decodes: category, bounding box, references, owner. +//! +//! Usage: +//! cargo run --profile ci -p rvt-probes --example probe_re317_area_boundaries -- FILE.rvt + +use rvt::RevitFile; +use rvt::research::partition_element_records::{ + BUILTIN_CATEGORY_MAX, BUILTIN_CATEGORY_MIN, CATEGORY_OFFSET, PLACEMENT_KIND_INSTANCE, + PLACEMENT_KIND_OFFSET, builtin_category_name, file_bbox_marker, partition_record_chain, + scan_category_records_multi, +}; +use rvt::research::partition_level_records::recover_partition_levels; +use rvt::research::partition_room_boundaries::solid_headers_by_id; +use rvt::research::partition_room_parameters::{ + DATA_OBJECT_HEADER, data_objects, scan_room_parameter_entries, +}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +const OST_AREAS: i64 = -2_003_200; +const OST_AREA_SCHEME_LINES: i64 = -2_000_079; +const OST_AREA_POLYLINES: i64 = -2_000_830; + +const OCCURRENCES_PER_AREA: usize = 40; +const OBJECT_HEX_BYTES: usize = 2048; +const CHAIN_HEX_BYTES: usize = 640; +const UNREFERENCED_LINE_SAMPLE: usize = 30; + +/// A chain record: start, end, ElementId, category. +type Span = (usize, usize, u32, i64); + +/// One copy of an Area's chain record: stream, offset, bytes. +type AreaCopy = (String, usize, Vec); + +/// A chain record of an area boundary line or polyline: ElementId, category, +/// stream, offset, size, its first bytes. +type LineRecord = (u32, i64, String, usize, usize, Vec); + +fn u32_at(buf: &[u8], at: usize) -> Option { + buf.get(at..at.checked_add(4)?) + .map(|b| u32::from_le_bytes(b.try_into().expect("4 bytes"))) +} + +fn i64_at(buf: &[u8], at: usize) -> Option { + buf.get(at..at.checked_add(8)?) + .map(|b| i64::from_le_bytes(b.try_into().expect("8 bytes"))) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn partition_number(stream: &str) -> u32 { + stream + .rsplit('/') + .next() + .and_then(|n| n.parse().ok()) + .unwrap_or(0) +} + +fn main() -> rvt::Result<()> { + let path = PathBuf::from(std::env::args().nth(1).expect("usage: FILE.rvt")); + let mut rf = RevitFile::open(&path)?; + let version = rf.basic_file_info()?.version; + let Some(marker) = file_bbox_marker(&mut rf, version) else { + println!("release {version}: no record marker"); + return Ok(()); + }; + let declared = rvt::elem_table::declared_ids(&rvt::elem_table::parse_records(&mut rf)?); + let recovered = recover_partition_levels(&mut rf, version)?; + let stories: BTreeMap> = recovered + .iter() + .map(|level| (level.element_id, level.building_story)) + .collect(); + let levels: BTreeMap = recovered + .into_iter() + .map(|level| (level.element_id, (level.name, level.elevation_feet))) + .collect(); + let global_ids = rvt::research::revit_global_ids::revit_global_ids(&mut rf).unwrap_or_default(); + println!("release {version}"); + for (id, (name, elevation)) in &levels { + let story = stories.get(id).copied().flatten(); + println!("level\t{id}\t{name}\t{elevation:.6}\t{story:?}"); + } + + let streams = rf.partition_stream_names(); + let mut census: BTreeMap = BTreeMap::new(); + let mut category_of: BTreeMap = BTreeMap::new(); + let mut spans_by_stream: BTreeMap> = BTreeMap::new(); + let mut area_copies: BTreeMap> = BTreeMap::new(); + let mut line_chain: Vec = Vec::new(); + for stream in &streams { + let Ok(inflated) = rf.inflated_partition(stream) else { + continue; + }; + let buf = inflated.bytes(); + let spans = spans_by_stream.entry(stream.clone()).or_default(); + for span in partition_record_chain(buf, &marker) { + let Ok(id) = u32::try_from(span.element_id) else { + continue; + }; + if !declared.contains(&id) { + continue; + } + let Some(category) = i64_at(buf, span.start + CATEGORY_OFFSET) + .filter(|c| (BUILTIN_CATEGORY_MIN..=BUILTIN_CATEGORY_MAX).contains(c)) + else { + continue; + }; + let size = span.end - span.start; + let entry = census.entry(category).or_insert((0, usize::MAX, 0)); + entry.0 += 1; + entry.1 = entry.1.min(size); + entry.2 = entry.2.max(size); + category_of.entry(id).or_insert(category); + spans.push((span.start, span.end, id, category)); + if category == OST_AREAS { + area_copies.entry(id).or_default().push(( + stream.clone(), + span.start, + buf[span.start..span.end].to_vec(), + )); + } + if category == OST_AREA_SCHEME_LINES || category == OST_AREA_POLYLINES { + line_chain.push(( + id, + category, + stream.clone(), + span.start, + size, + buf[span.start..span.end.min(span.start + CHAIN_HEX_BYTES)].to_vec(), + )); + } + } + } + for (category, (count, smallest, largest)) in &census { + println!( + "cat\t{category}\t{}\t{count}\t{smallest}\t{largest}", + builtin_category_name(*category).unwrap_or("?") + ); + } + + let area_ids: BTreeSet = area_copies.keys().copied().collect(); + println!( + "areas\t{}\tarea boundary line chain records {}", + area_ids.len(), + line_chain + .iter() + .filter(|l| l.1 == OST_AREA_SCHEME_LINES) + .count() + ); + let entries = scan_room_parameter_entries(&mut rf, version, &area_ids); + let mut referenced: BTreeSet = BTreeSet::new(); + for (id, copies) in &area_copies { + let Some(latest) = copies.iter().max_by_key(|c| partition_number(&c.0)) else { + continue; + }; + let bytes = &latest.2; + let mut named_levels = BTreeSet::new(); + let mut refs = Vec::new(); + for at in CATEGORY_OFFSET..bytes.len().saturating_sub(7) { + let Some(value) = i64_at(bytes, at) + .and_then(|v| u32::try_from(v).ok()) + .filter(|v| *v > 1000 && v != id && declared.contains(v)) + else { + continue; + }; + if levels.contains_key(&value) { + named_levels.insert(value); + refs.push(format!("{at:#x}:{value}:level")); + } else if let Some(category) = category_of.get(&value) { + referenced.insert(value); + refs.push(format!("{at:#x}:{value}:{category}")); + } else { + refs.push(format!("{at:#x}:{value}:-")); + } + } + let level = match named_levels.iter().collect::>().as_slice() { + [only] => format!("{only}\t{:.6}", levels[*only].1), + _ => "-\t-".to_string(), + }; + let (number, name) = entries + .get(id) + .map_or(("-", "-"), |(n, m)| (n.as_str(), m.as_str())); + let placed = copies + .iter() + .all(|c| u32_at(&c.2, PLACEMENT_KIND_OFFSET) == Some(PLACEMENT_KIND_INSTANCE)); + println!( + "area\t{id}\t{}\t{number}\t{name}\t{level}\t{}\t{}", + global_ids.get(id).map_or("-", String::as_str), + if placed { "placed" } else { "not placed" }, + refs.join(",") + ); + println!("rec\t{id}\t{}\t{}\t{}", latest.0, latest.1, hex(bytes)); + } + + let mut interesting: BTreeSet = area_ids.clone(); + interesting.extend(&referenced); + let mut occurrences: BTreeMap = BTreeMap::new(); + for stream in &streams { + let Ok(inflated) = rf.inflated_partition(stream) else { + continue; + }; + let buf = inflated.bytes(); + let spans = spans_by_stream + .get(stream) + .map(Vec::as_slice) + .unwrap_or(&[]); + for (at, object) in data_objects(buf) { + if area_ids.contains(&object.element_id) { + let payload = &buf[at + DATA_OBJECT_HEADER..object.end]; + println!( + "obj\t{}\t{stream}\t{at}\t{:#x}\t{}\t{}", + object.element_id, + object.class, + payload.len(), + hex(&payload[..payload.len().min(OBJECT_HEX_BYTES)]) + ); + } + } + for &id in &area_ids { + let needle = u64::from(id).to_le_bytes(); + for at in memchr::memmem::find_iter(buf, &needle) { + let count = occurrences.entry(id).or_insert(0); + if *count >= OCCURRENCES_PER_AREA { + break; + } + *count += 1; + let holder = spans + .partition_point(|s| s.0 <= at) + .checked_sub(1) + .and_then(|i| spans.get(i)) + .filter(|s| at < s.1); + let (record, category, relative) = match holder { + Some(s) => (s.2.to_string(), s.3.to_string(), (at - s.0).to_string()), + None => ("-".to_string(), "-".to_string(), "-".to_string()), + }; + println!( + "occ\t{id}\t{stream}\t{at}\t{record}\t{category}\t{relative}\t{}", + hex(&buf[at.saturating_sub(16)..buf.len().min(at + 56)]) + ); + } + } + let solids = solid_headers_by_id(buf); + for &id in &interesting { + if let Some(headers) = solids.get(&u64::from(id)) { + println!("solid\t{id}\t{stream}\t{}", headers.len()); + } + } + for &(start, end, id, category) in spans { + if referenced.contains(&id) && category != OST_AREAS { + println!( + "chain\t{id}\t{category}\t{stream}\t{start}\t{}\t{}", + end - start, + hex(&buf[start..end.min(start + CHAIN_HEX_BYTES)]) + ); + } + } + } + let mut sampled = 0; + for (id, category, stream, start, size, bytes) in &line_chain { + if referenced.contains(id) || sampled >= UNREFERENCED_LINE_SAMPLE { + continue; + } + sampled += 1; + println!( + "chain\t{id}\t{category}\t{stream}\t{start}\t{size}\t{}", + hex(bytes) + ); + } + + let lines = scan_category_records_multi( + &mut rf, + version, + &[OST_AREA_SCHEME_LINES, OST_AREA_POLYLINES], + &declared, + )?; + println!("lines\t{}", lines.len()); + for record in &lines { + let join = |ids: &[u64]| ids.iter().map(u64::to_string).collect::>().join(","); + println!( + "line\t{}\t{}\t{}\t{}\t{}\t{}\t{:#x}\t{}\t{}\t{}\t{}", + record.element_id, + record.builtin_category, + record.stream, + record.offset, + record.class_tag, + record + .container_element_id() + .map_or("-".to_string(), |c| c.to_string()), + record.placement_kind, + record + .bbox_feet + .iter() + .map(|v| format!("{v:.6}")) + .collect::>() + .join(","), + record + .owner_reference + .map_or("-".to_string(), |o| o.to_string()), + join(&record.references), + record + .design_option + .map_or("-".to_string(), |d| d.to_string()), + ); + } + Ok(()) +} diff --git a/reports/element-framing/RE-317-area-boundaries.md b/reports/element-framing/RE-317-area-boundaries.md new file mode 100644 index 000000000..29062ca5a --- /dev/null +++ b/reports/element-framing/RE-317-area-boundaries.md @@ -0,0 +1,101 @@ +# RE-317: an Area's boundary, from the area boundary lines around its point + +**Date:** 2026-11-03 +**Issues:** #763 (Areas as spaces); follows RE-191 (the Area records), RE-313 (every `IfcSpace` scored against Revit's) and RE-101 (a room's outline from its stored solid) +**Artefacts:** the BIM4LCA office, concrete structure, as built and at building permit (Revit 2024), with Revit's own IFC export of each (`research/bim4lca-measure.tsv`, CC BY-SA 4.0, read by byte range for a Measure run and never stored); the six reference models and puzzbobb's R2025 models (no Area in any); the held-out models, by counts only +**Probe and scorers:** `probes/examples/probe_re317_area_boundaries.rs` with `tools/re/area_boundaries_vs_ifc.py`; the exported bodies by `tools/re/space_profiles_vs_ifc.py` (RE-313) +**Runs:** the probe, [41705217941](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41705217941), and again at the final head with the point check, [41713925390](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41713925390); the Level settings of the office, [41721498984](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41721498984); the change merged with `main`, unfiltered, with the nordic, R2025, held-out and every Autodesk sample, [41740909229](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41740909229) (merged head 37816e3b against `main` at 4ad28023). Before the merge: the full run [41731793484](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41731793484) and the samples [41731810411](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41731810411) at cdc21cf6 against `main` at 7fd339e2, and the two Vitoria held-out models against the then merge base 8664b94a, [41737066071](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41737066071) (earlier versions of the change: [41708150034](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41708150034) at febba58f, [41723183283](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41723183283) and [41723200570](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41723200570) at 5a7e40ac) +**Status:** positive on both office files. All 30 Areas of each are drawn, and each equals Revit's in area (within 0.1%), in outline (within 0.01 ft) and in base and top (within 0.01 ft); no other export moved. + +## 1. Before + +Revit's export draws each of the office's 30 Areas as an `IfcExtrudedAreaSolid` (RE-191 §3). rvt-rs wrote the 30 `IfcSpace` with number, name and storey and without a body, `SpaceOutlineSource` `none` (RE-313). The Area's chain record holds no box and no boundary. + +## 2. Where the boundary is + +The Area stores no boundary. What its record names, and what Revit draws, are these: + +- **Its area boundary lines.** The record's id list names the Level, the area scheme (category `OST_AreaSchemes`, -2003201), and two elements of category `OST_AreaSchemeLines` (-2000079). Each of the office's 689 area boundary lines is a chain record of 217 to 241 bytes with the bounding-box marker, so the element-record scan reads it: its box is the line itself, since every one of the 689 is along an axis (346 horizontal, 343 vertical). Its reference list names its Level and its scheme, one of each on all 689, and its height is its Level's elevation to 1e-4 ft on all 689. The office's four schemes (`GFA 250mm`, `GFA`, `OFFICE`, `HEATED NETAREA`) hold 689 lines on 10 Levels, in 21 groups of a scheme on a Level. +- **A point.** Two `f64`s at byte 107 of the payload of the Area's data object (class 0x0e1c, RE-153) are a point in plan feet. It lies inside Revit's profile of the Area, the profile's holes taken out, on 30 of 30 Areas on both files (`area_boundaries_vs_ifc`, "point inside Revit's profile", run 41713925390), and the two lines the Area's record names lie on the outline of Revit's profile on 30 of 30. +- **Its height.** Revit's exporter ignores an Area's own upper Level (`GetScaledHeight` in revit-ifc at `IFC_v24.3.40`, CLEANROOM.md source 5, read for what it writes, not copied) and takes the height from `LevelUtil.CalculateDistanceToNextLevel`: the distance to its Level's story above (`LEVEL_UP_TO_LEVEL`) where that is a building story, else the distance to the next building story. The story above is not read. The extrusion depth of all 30 Areas is the distance to the Level above it, which is the next building story, to 0.0001 ft (11.1549 ft from Level_01 to Level_02a_Parking, 12.9593 from Level_02, 14.1732 from Level_04, 19.5866 from Level_05 to the Roof); so on the office a Level's story above is the next Level up, or is not set. The body's lowest height is its Level's elevation to 0.0001 ft on all 30 (the Area's base offset, which the file would hold if it were not zero, is read as zero); and `Qto_SpaceBaseQuantities.Height` is the same number. + +## 3. The rule + +The region of an Area is the one that the lines of its scheme on its Level enclose around its point. Revit's profile is exactly that on 30 of 30 Areas on both files (the symmetric difference of the two regions is 0.000 ft², checked with a prototype of the rule over the probe's lines and Revit's profile points, and then by the exporter in §5): + +1. Take every line of the Area's scheme on its Level. Lines meet at their ends; a line that ends on another, or crosses it, splits both there. The two groups of the office that have such lines are `GFA` on Level_01 (a line ends on the middle of another's edge in two places, so that the Area `1` and the Area `P` share part of the edge) and `HEATED NETAREA` on the Basement (a line crosses another and both end beyond the crossing). +2. A line with a free end bounds nothing and is left out, repeatedly (by a worklist of the points that have one line left, so the pass is linear in the lines). The Basement's `HEATED NETAREA` has two such stubs, 0.66 and 1.1 ft long. +3. The Area is the smallest loop that encloses its point, and its holes are the loops directly inside that loop, whether or not an Area sits in them. Six `OFFICE` Areas have three or four holes each, `GFA 250mm` has two on each of four Levels and one on Level_05. +4. A vertex where two lines continue in a straight line is not a vertex of the outline (the `OFFICE` Area `TSTO3` has one, where Revit's profile has 32 vertices and the lines 33); a vertex where a third line meets is, and Revit's profile has it (the `GFA` Areas `1` and `P`, six vertices each). + +**The point decides the side.** An Area's two named lines are on its boundary, but not always on its outer loop: those of `5` (`GFA 250mm`, Level_05) are on a rectangle inside a larger loop, and Revit's Area is the larger loop with the rectangle as a hole (1857.7 ft²), not the rectangle (208.3 ft²). Choosing the loop that holds the named lines would draw 29 of 30. The point separates the two, and the named lines are then a check: they must be on the region's boundary. + +## 4. What changes + +`partition_area_boundaries::Arrangement` builds the arrangement of a set of straight lines along the plan axes: the splits, the free ends, the rings of the remaining edges with the region on their left, and which ring is nested in which. `face_at` returns the region around a point as an outer loop and its holes. It returns `None` where the lines are not all along the axes, overlap, are more than 2048, or where the point is on a line, in no loop or in two of one size, or where the loop or one of its holes touches itself. + +`partition_area_records` reads, for each placed Area of a Revit 2024 file: its point; its Level, its scheme and its named lines from its record; the lines of that scheme on that Level; and the elevation of its Level and the distance to the Level above it. It draws a body only where every one of these holds, and none otherwise, saying why in `AreaBodyRefusal` and counting the reasons in `exported.area_body_refusals`: + +- the Area's record names one Level, one scheme and at least one line, and every line it names is a declared line (from the chain records, not only one the scan returned) of that scheme on that Level whose copies agree (`named_lines_not_in_scheme_and_level` otherwise: a named line with no Level or scheme, with copies that disagree, or in a non-primary design option); +- every line of the scheme on the Level is along an axis at the Level's height, has one copy or agreeing copies, is a placed instance, and has a Level and a scheme in its reference list (a line that has not, or whose copies disagree, may belong to any scheme on its Level: the group is not read). Every declared area boundary line of the file, from the chain records, must also be one the scan returned: a declared line the scan did not return, or one whose height is not a number, may belong to any group, so no Area of the file is drawn (`boundary_line_not_read`); +- the point lies in exactly one region and the named lines bound it; +- the Area's Level and the Level above it are both read as building stories, and no other Level lies between them or at either elevation (`next_storey_not_proven` otherwise: the top storey, a Level that is not a story or whose setting is not read, or two Levels at one elevation); +- no other placed Area of the same scheme and Level has the same region, whatever stops that Area from being drawn: every Area that has a point and names a Level and a scheme gets its region, and an Area whose region another shares has none (`two_areas_one_region`), since Revit's second, redundant Area has no area of its own; +- no line of the group is in a non-primary design option: such a line is not skipped but makes its group unreadable (`boundary_lines_not_readable`). No area boundary line of the office or of Autodesk's 2024 samples has a design option (the probe's `line` rows), so how Revit treats one is not measured. + +The exporter writes the region as the profile of an extrusion at the Level's elevation of the distance to the Level above. `RvtElementRecordGeometry.SpaceOutlineSource` is `area_boundary`, `BodySource` and `ProfileSource` are `partition_area_boundary_lines`, `BodyHeightSource` is `area_level_to_next_storey`, and an Area without a body carries `AreaBodyRefusal`. An Area drawn as a body is not a room, so it is left out of the spaces that hold a room's furniture (`contain_in_spaces`); Revit puts a family instance in its room. + +## 5. Results + +Measure, unfiltered, the branch with `main` merged in (37816e3b; `main` at 4ad28023 holds #1286, #1292 and the door widths) against `main`, run 41740909229 (`-f base=main -f nordic=true -f r2025=true -f samples=true -f holdout=true`, every scorer). The "before" columns are `main` as it now is, which reads 13 of the office's rooms again (RE-316). `space_profiles_vs_ifc` on the two office files: + +| | as built before | as built after | permit before | permit after | +|---|---:|---:|---:|---:| +| `IfcSpace` with a body (rvt-rs / Revit) | 278 / 308 | 308 / 308 | 278 / 308 | 308 / 308 | +| in both | 276 | 306 | 278 | 308 | +| only in Revit, rvt-rs writes it without a body | 30 | 0 | 30 | 0 | +| area within 0.1% | 230 | 260 | 232 | 262 | +| outline within 0.01 ft | 220 | 250 | 225 | 255 | +| boundary within 0.01 ft | 230 | 260 | 232 | 262 | +| lowest and highest vertex within 0.01 ft | 276 of 276 | 306 of 306 | 278 of 278 | 308 of 308 | +| area, outline and boundary within tolerance, the Areas | none drawn | 30 of 30 | none drawn | 30 of 30 | +| base and top within 0.01 ft, the Areas | none drawn | 30 of 30 | none drawn | 30 of 30 | + +The `rvt-rs area area boundary` line reads "30, area within 0.1% 30, outline within 0.01 ft 30, both 30, boundary within 0.01 ft 30" on each file, and its height line "30, lowest and highest vertex within 0.01 ft 30". The off counts (44 and 46 in area, 56 and 53 in outline) are the family instances and the rooms RE-316 reads, as on `main`; its lines for the room kinds are the same on both sides. The earlier runs of the first and second versions of the change (against `main` before RE-316) gave the same 30 of 30 and the same Area counts; the later rules (every declared line read, the Level above a building story, the region shared with every placed Area, the named lines checked against the declared census, a line in a non-primary design option making its group unreadable) lose none of them. + +- **No other export moved.** Of the 111 models and samples the run exports, the hash of the IFC differs on four: the two office files and the two 2024 samples with Areas (below). Core Interior, RE1, the R2025 models, the terrain variants, every held-out model and every other sample, of all releases 2015 to 2027, are byte-identical. The compare job's scorer diff is 68 lines: the space lines of the office files (the old ones and the new height lines), the count of swept solids that carry a Position distinct from their product placement in `schema.txt` (12747 to 12777 and 12790 to 12820 on the office, the 30 new bodies; 5600 to 5603 on `rac_advanced`) and nothing else. The property set, quantity, plan profile, class set, room name and storey, tagged space and WHERE-rule results of the office print the same, so the Areas add no property value Revit does not write and no storey moved. +- **Diagnostics.** `exported.body_sources.partition_area_boundary_lines` is 30, `building_elements_with_geometry` rises by 30 (12757 to 12787 as built, 12803 to 12833 at permit) and the geometry element fraction goes from 0.9797 to 0.9820. `exported.area_body_refusals` is empty on both: every Area is drawn. +- **Held-out files, by counts.** Every held-out `space profiles:` count line is the same on both sides, the new height lines included, and no held-out model is in the scorer diff. BIM Whale 2021, whose 8 spaces are Areas of a 'Rentable' scheme, still has none of them written (0 of 8), since 2021 is not read for Areas. +- **Gates.** Clippy, rustdoc, the MSRV build, fmt and the PII guard pass on 37816e3b; the full CI result is in the PR. +- **Autodesk's samples** (the same run, base `main` at 4ad28023, no Revit export to score them against): `rac_advanced` (2024) has 25 Areas of one scheme on three Levels, and 3 gain a body; `exported.area_body_refusals` there is 18 `boundary_lines_not_readable` and 4 `point_in_no_single_region`. `rst_basic`'s one Area is refused as `next_storey_not_proven`. No other sample of any release has an Area read, and their exports are byte-identical; the two that moved moved by the 3 bodies and by the new `AreaBodyRefusal` property. The probe on the 2024 samples (run 41714706290 at 730ffb7b): the 18 are on two Levels whose lines include 4 that are not along an axis (a slanted line or an arc, whose box is not the line), so those groups are not read, as intended. The 4 others are on the third Level, whose 38 lines are all along an axis: the group has three closed loops and one open chain of 18 points, and these 4 have their point in no loop. The 3 bodies are not measured against Revit's. + +The prototype that fixed the rule (a Python version of §3 over the probe's lines and Revit's profile points) drew 30 of 30 with a zero symmetric difference before any Rust was written; the exporter's 30 of 30 above is the independent check. Vertex to vertex (`vertices`, the mesh's upward-face vertices), 29 of the 30 Areas agree on each file (86 to 115 and 84 to 113); the one that does not has Revit's area and outline, and why its vertices differ is not examined. + +## 6. Not measured + +- Revit releases other than 2024: no Area of another release is measured against an export, and nothing is read (fail closed). +- Boundary lines that are not along an axis (a slanted line, an arc): none on the office. A group that has one is not read, so its Areas keep no body, as before. +- An Area's base offset: read as zero, which the office measures. +- A Level's story above (`LEVEL_UP_TO_LEVEL`): Revit's exporter takes it first for an Area's height, and rvt-rs never reads it. On the office the height equals the distance to the next Level up on all 30, so a Level whose story above is another Level is unmeasured. The Area's own upper Level is ignored by Revit's exporter for an Area and is not read here either. +- Levels that are not building stories: an Area has a body only where its Level and the Level above are both read as building stories and no other Level lies between them or at either elevation. All ten of the office's Levels are building stories (the probe prints the setting of each, run 41721498984), so a Level that breaks this is not measured. +- The top storey: an Area on the highest Level has no body, where Revit draws it one unit high. Nothing on the office. +- Areas bounded partly by walls. Autodesk's `rac_advanced` is the one file with Areas whose lines do not all close: of the 7 Areas on its third Level, whose 38 lines are all along an axis, 3 sit in closed loops and are drawn, and the other 4 have their point in no loop of the lines (the group has three closed loops and one open chain of 18 points). That is what an Area bounded partly by walls would look like, and it is the main gap in this rule; what bounds them is not examined, because Revit's export of the sample is not available to compare with. The free-end rule above is measured on the office's two stubs only; on `rac_advanced` the open chain is one that the rule prunes, and whether Revit ignores its free ends there is not measured either. +- Area polylines and the other Area kinds (`OST_AreaPolylines`, -2000830). +- Revit's phase filter, which skips a spatial element in an invalid phase (RE-184). +- The held-out BIM Whale 2021 project's 8 Areas ('Rentable' scheme): 2021 is not read for Areas, and the file is held out, so it was not examined. It is quoted by its counts only. + +## Reproduce + +```bash +# once landed, --ref main (the scorers are on main then); before, --ref feat/area-spaces-bodies +gh workflow run measure.yml --ref main -f ref=37816e3b2823a8721f5ec8ee1becf981eab98aa0 -f base=none -f nordic=true -f models="nordic-office-as-built nordic-office-permit" \ + -f scorers=none -f probe=probe_re317_area_boundaries -f probe_scorer=area_boundaries_vs_ifc +gh workflow run measure.yml --ref main -f ref=37816e3b2823a8721f5ec8ee1becf981eab98aa0 -f base=main -f nordic=true -f r2025=true -f samples=true -f holdout=true +gh workflow run measure.yml --ref main -f ref=37816e3b2823a8721f5ec8ee1becf981eab98aa0 -f base=main -f samples=true -f sample_years="2024 2024" -f models="zzz-no-model" +# the probe on Autodesk's 2024 samples (run 41714706290 at 730ffb7b; the probe has no scorer there) +gh workflow run measure.yml --ref main -f ref=730ffb7b457c9cb282e3d317c42980b123bda184 -f base=none -f samples=true -f sample_years="2024 2024" -f models="zzz-no-model" -f probe=probe_re317_area_boundaries +python3 tools/re/area_boundaries_vs_ifc.py probe.txt revit-export.ifc +python3 tools/re/space_profiles_vs_ifc.py model.ifc revit-export.ifc --list 10 +``` + +The first version of the change, which `main` does not hold, is 46783fe9 (run 41708150034 at febba58f). diff --git a/src/ifc/export_content.rs b/src/ifc/export_content.rs index 7bf356c87..a74353348 100644 --- a/src/ifc/export_content.rs +++ b/src/ifc/export_content.rs @@ -267,11 +267,76 @@ fn identity_property_set(mut set: PropertySet, decoded: &DecodedElement) -> Prop set } +/// An Area's body (RE-317): its placement, the extrusion of its boundary and +/// the profile that made it. +struct AreaDrawn { + location: [f64; 3], + body: Extrusion, + profile: crate::element_record_plan_profiles::PlanProfile, +} + +/// The body an Area's decoded fields hold: the profile its boundary lines +/// made, the elevation of its base and its height. +fn area_body_from_decoded(decoded: &DecodedElement) -> Option { + let from_lines = decoded.fields.iter().any(|(field, value)| { + field == crate::element_record_plan_profiles::PLAN_PROFILE_SOURCE_FIELD + && matches!(value, InstanceField::String(source) + if source == crate::partition_area_records::AREA_BOUNDARY_SOURCE) + }); + if !from_lines { + return None; + } + let profile = crate::element_record_plan_profiles::plan_profile_from_fields(&decoded.fields)?; + if !profile.pieces.is_empty() { + return None; + } + let float = |wanted: &str| { + decoded + .fields + .iter() + .find_map(|(field, value)| match value { + InstanceField::Float { value, .. } if field == wanted => Some(*value), + _ => None, + }) + }; + let base = float(crate::partition_area_records::AREA_BODY_BASE_FIELD)?; + let height = float(crate::partition_area_records::AREA_BODY_HEIGHT_FIELD)?; + if !(base.is_finite() && height.is_finite() && height > 0.0) { + return None; + } + let bounds = profile.plan_bounds_feet()?; + let centre = ((bounds[0] + bounds[2]) / 2.0, (bounds[1] + bounds[3]) / 2.0); + let relative = |ring: &[(f64, f64)]| -> Vec<(f64, f64)> { + ring.iter() + .map(|(px, py)| (px - centre.0, py - centre.1)) + .collect() + }; + let points = relative(&profile.outer_xy); + let voids: Vec> = profile.inner_xy.iter().map(|ring| relative(ring)).collect(); + let section = if voids.is_empty() { + entities::ProfileDef::ArbitraryClosed { points } + } else { + entities::ProfileDef::ArbitraryWithVoids { points, voids } + }; + Some(AreaDrawn { + location: [centre.0, centre.1, base], + body: Extrusion { + width_feet: bounds[2] - bounds[0], + depth_feet: bounds[3] - bounds[1], + height_feet: height, + profile_override: Some(section), + }, + profile, + }) +} + /// An Area's ElementId, number, name and Level (#763), in the set a room's /// are written in, so the writer names its `IfcSpace` as it names a room's /// (`Name` the number and `LongName` the name, as Revit's export does) and /// `ifc::apply_record_level_reference_storeys` puts it in its Level's storey. -fn area_property_set(decoded: &DecodedElement) -> PropertySet { +/// Where its boundary is read (RE-317) the set also says where the body came +/// from. +fn area_property_set(decoded: &DecodedElement, drawn: Option<&AreaDrawn>) -> PropertySet { let text = |wanted: &str| { decoded .fields @@ -325,10 +390,68 @@ fn area_property_set(decoded: &DecodedElement) -> PropertySet { ), }); } - // RE-313: an Area is written without a body, so without an outline. + // RE-313, RE-317: an Area is drawn from its boundary lines where they + // give it a region, and written without a body, so without an outline, + // where they do not. + if let Some(drawn) = drawn { + for (name, value) in [ + ( + "BodySource", + PropertyValue::Text(crate::partition_area_records::AREA_BOUNDARY_SOURCE.into()), + ), + ("ProfileResolved", PropertyValue::Boolean(true)), + ( + "ProfileSource", + PropertyValue::Text(crate::partition_area_records::AREA_BOUNDARY_SOURCE.into()), + ), + ( + "ProfileVertexCount", + PropertyValue::Integer(drawn.profile.vertex_count() as i64), + ), + ( + "ProfileVoidCount", + PropertyValue::Integer(drawn.profile.inner_xy.len() as i64), + ), + ( + "BodyHeightSource", + PropertyValue::Text(AREA_BODY_HEIGHT_SOURCE.into()), + ), + ] { + properties.push(Property { + name: name.into(), + value, + }); + } + } + if drawn.is_none() { + let refusal = decoded + .fields + .iter() + .find_map(|(field, value)| match value { + InstanceField::String(reason) + if field == crate::partition_area_records::AREA_BODY_REFUSAL_FIELD => + { + Some(reason.clone()) + } + _ => None, + }); + if let Some(reason) = refusal { + properties.push(Property { + name: AREA_BODY_REFUSAL_PROPERTY.into(), + value: PropertyValue::Text(reason), + }); + } + } properties.push(Property { name: SPACE_OUTLINE_SOURCE_PROPERTY.into(), - value: PropertyValue::Text(SPACE_OUTLINE_NONE.into()), + value: PropertyValue::Text( + if drawn.is_some() { + SPACE_OUTLINE_AREA_BOUNDARY + } else { + SPACE_OUTLINE_NONE + } + .into(), + ), }); PropertySet { name: ELEMENT_RECORD_PROPERTY_SET.into(), @@ -754,9 +877,17 @@ pub fn append_typed_production_elements( property_set = Some(geom); } } - // #763: an Area's record holds no box, so its space has - // no body, but it is named as a room's is. - "Area" => property_set = Some(area_property_set(&decoded)), + // #763: an Area's record holds no box, but it is named as a + // room's is. RE-317: where its boundary lines give it a + // region, that is its body. + "Area" => { + let drawn = area_body_from_decoded(&decoded); + property_set = Some(area_property_set(&decoded, drawn.as_ref())); + if let Some(drawn) = drawn { + location_feet = Some(drawn.location); + extrusion = Some(drawn.body); + } + } _ => {} } } @@ -3863,6 +3994,15 @@ pub(super) fn contain_in_spaces(entities: &mut Vec) { }) }) }; + let is_area = |set: &Option| { + set.as_ref().is_some_and(|set| { + set.properties.iter().any(|p| { + p.name == SPACE_OUTLINE_SOURCE_PROPERTY + && matches!(&p.value, PropertyValue::Text(source) + if source == SPACE_OUTLINE_AREA_BOUNDARY) + }) + }) + }; let mut spaces = Vec::new(); for (index, entity) in entities.iter().enumerate() { let entities::IfcEntity::BuildingElement { @@ -3878,8 +4018,9 @@ pub(super) fn contain_in_spaces(entities: &mut Vec) { continue; }; // #763: a family instance written as a space holds nothing; the - // furniture around it stays in its room. - if ifc_type != "IFCSPACE" || is_family_instance(property_set) { + // furniture around it stays in its room. RE-317: nor does an Area, + // which Revit's export draws over its rooms. + if ifc_type != "IFCSPACE" || is_family_instance(property_set) || is_area(property_set) { continue; } if let Some((outer, voids)) = @@ -4208,9 +4349,23 @@ pub const SPACE_OUTLINE_RECORD_BOX: &str = "record_box"; /// for it (`rvt-ifc --saved-meshes`). pub const SPACE_OUTLINE_SAVED_MESH: &str = "saved_mesh"; -/// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of an Area, which has no body. +/// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of an Area whose boundary is not read, so +/// it has no body. pub const SPACE_OUTLINE_NONE: &str = "none"; +/// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of an Area drawn as the region its +/// scheme's boundary lines enclose around its point, from its Level to the +/// Level above, when both are building stories (RE-317). +pub const SPACE_OUTLINE_AREA_BOUNDARY: &str = "area_boundary"; + +/// `BodyHeightSource` of an Area's body: from its Level up to the Level above +/// it, when both are building stories (RE-317). +pub const AREA_BODY_HEIGHT_SOURCE: &str = "area_level_to_next_storey"; + +/// Property of an Area's [`ELEMENT_RECORD_PROPERTY_SET`] that says why it has no +/// body, where its boundary was not proven (RE-317). +pub const AREA_BODY_REFUSAL_PROPERTY: &str = "AreaBodyRefusal"; + /// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of a Revit Space or a family instance /// written as an `IfcSpace` whose body is more than its record's box: its /// `BodySource` says how it was drawn. diff --git a/src/ifc/mod.rs b/src/ifc/mod.rs index defea804d..424a81a90 100644 --- a/src/ifc/mod.rs +++ b/src/ifc/mod.rs @@ -678,6 +678,11 @@ pub struct ExportedModelDiagnostics { /// names the turned box. #[serde(default)] pub bounding_box_spaces: usize, + /// Of the exported Areas that have no body, why, by reason: the + /// `AreaBodyRefusal` property of each (RE-317), such as + /// `boundary_lines_not_readable` or `point_in_no_single_region`. + #[serde(default)] + pub area_body_refusals: std::collections::BTreeMap, /// Of the exported `IfcSpace`s, the rooms whose outline was read from the /// faces their stored solid lists, up to the solid's own end, where the /// walk of RE-101 did not close them (`SpaceOutlineSource` is @@ -4234,6 +4239,7 @@ fn exported_model_diagnostics(model: &IfcModel) -> ExportedModelDiagnostics { let mut body_sources = std::collections::BTreeMap::::new(); let mut bounding_box_bodies = 0usize; let mut bounding_box_spaces = 0usize; + let mut area_body_refusals = std::collections::BTreeMap::::new(); let mut resolved_outline_spaces = 0usize; let mut approximate_outline_spaces = 0usize; for (index, entity) in model.entities.iter().enumerate() { @@ -4260,6 +4266,10 @@ fn exported_model_diagnostics(model: &IfcModel) -> ExportedModelDiagnostics { let mut profile_resolved = false; for property in properties { match (property.name.as_str(), &property.value) { + ( + export_content::AREA_BODY_REFUSAL_PROPERTY, + entities::PropertyValue::Text(text), + ) => *area_body_refusals.entry(text.clone()).or_insert(0) += 1, ("BodySource", entities::PropertyValue::Text(text)) => source = Some(text), ( export_content::SPACE_OUTLINE_SOURCE_PROPERTY, @@ -4343,6 +4353,7 @@ fn exported_model_diagnostics(model: &IfcModel) -> ExportedModelDiagnostics { body_sources, bounding_box_bodies, bounding_box_spaces, + area_body_refusals, resolved_outline_spaces, approximate_outline_spaces, } diff --git a/src/ifc/schedule_csv.rs b/src/ifc/schedule_csv.rs index e34ee1407..d554896cb 100644 --- a/src/ifc/schedule_csv.rs +++ b/src/ifc/schedule_csv.rs @@ -305,7 +305,9 @@ pub fn rooms_csv(model: &IfcModel, options: &CsvOptions) -> String { (property("ProfileSource") == crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE || property("ProfileSource") - == crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE_FACES) + == crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE_FACES + || property("ProfileSource") + == crate::partition_area_records::AREA_BOUNDARY_SOURCE) .then(|| extrusion.as_ref().and_then(outline_area_square_feet)) .flatten() .map(|area| unit.convert_area(area)), diff --git a/src/lib.rs b/src/lib.rs index 15dd7aac9..ccc4c2886 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -225,7 +225,7 @@ pub(crate) use research::{ native_parametric_mesh, native_parametric_surface, native_phases, native_rendered_content, native_saved_mesh, native_saved_scene, native_segments, native_stairs, native_symbol_cuts, native_type_materials, native_wall_types, native_walls, partition_arc_walls, - partition_area_records, partition_beam_axes, partition_column_nodes, + partition_area_boundaries, partition_area_records, partition_beam_axes, partition_column_nodes, partition_compound_structure, partition_connector_pairs, partition_curtain_materials, partition_curve_fields, partition_design_options, partition_element_records, partition_element_records_2023, partition_fitting_sizes, partition_id_objects, diff --git a/src/research.rs b/src/research.rs index 115249f87..9d8572efa 100644 --- a/src/research.rs +++ b/src/research.rs @@ -15,6 +15,7 @@ //! ## Partition records //! //! - [`partition_arc_walls`]: Shared partition ArcWall iteration (RE-15) +//! - [`partition_area_boundaries`]: The region an Area occupies among its area boundary lines (RE-317) //! - [`partition_area_records`]: Revit 2024 Areas from their partition chain records (#763) //! - [`partition_beam_axes`]: Beam location lines from their serialised element data (RE-49) //! - [`partition_column_nodes`]: A column's true z-range from its instance geometry node (RE-173) @@ -139,6 +140,7 @@ pub mod native_type_materials; pub mod native_wall_types; pub mod native_walls; pub mod partition_arc_walls; +pub mod partition_area_boundaries; pub mod partition_area_records; pub mod partition_beam_axes; pub mod partition_column_nodes; diff --git a/src/research/element_record_plan_profiles.rs b/src/research/element_record_plan_profiles.rs index 55618e45d..84a261132 100644 --- a/src/research/element_record_plan_profiles.rs +++ b/src/research/element_record_plan_profiles.rs @@ -290,6 +290,7 @@ pub fn plan_profile_from_fields(fields: &[(String, InstanceField)]) -> Option { source_ok = text == PLAN_PROFILE_SOURCE || crate::partition_room_boundaries::is_room_outline_source(text) + || text == crate::partition_area_records::AREA_BOUNDARY_SOURCE || text == crate::partition_schema_mvp::LANDING_OUTLINE_SOURCE; } (PLAN_PROFILE_OUTER_FIELD, field) => outer = points_from_field(field), diff --git a/src/research/partition_area_boundaries.rs b/src/research/partition_area_boundaries.rs new file mode 100644 index 000000000..99e87ff9e --- /dev/null +++ b/src/research/partition_area_boundaries.rs @@ -0,0 +1,557 @@ +//! The face a Revit Area occupies among its area boundary lines (RE-317). +//! +//! An Area's boundary is not stored in the Area. The lines that make it are +//! elements of their own, `OST_AreaSchemeLines` (-2000079), each with a +//! bounding-box record (the same frame as any element record), a Level and an +//! area scheme in its reference list, and no more. Revit draws an Area as the +//! region of the plane that its scheme's lines on its Level enclose around the +//! Area's point: +//! +//! - lines meet at their ends, and a line that ends on another, or crosses it, +//! splits both there; +//! - a line with a free end bounds nothing and is left out, as a line that +//! leads nowhere; +//! - a region is bounded by a loop of lines, and every loop that lies directly +//! inside it is a hole of it, whether or not another Area sits in that +//! hole; +//! - a vertex where two lines continue in a straight line is not a vertex of +//! the outline, and a vertex where a third line meets is. +//! +//! This module holds that geometry for straight lines along the plan axes, +//! which is every line of the office. [`Arrangement::new`] returns `None` for +//! a set it cannot read as such, and [`Arrangement::face_at`] returns `None` +//! for a point it cannot place in exactly one region bounded by simple +//! loops, so nothing is drawn that is not proven. The Area's point and the +//! rest come from [`crate::partition_area_records`]. + +use std::collections::{BTreeMap, BTreeSet}; + +/// How close two points, or a point and a line, must be to be the same, feet. +pub const NODE_TOLERANCE_FEET: f64 = 1e-5; + +/// The most lines one arrangement takes. A set of more is not read. +pub const MAX_LINES: usize = 2048; + +/// The most points one arrangement takes, its lines' ends and the places they +/// cross included. Lines that cross in more places are not read. +pub const MAX_NODES: usize = 200_000; + +/// The largest coordinate taken, feet. +pub const MAX_COORDINATE_FEET: f64 = 1.0e7; + +/// The smallest plan area of a ring that bounds a region, square feet. +pub const MIN_RING_AREA: f64 = 1e-6; + +/// One area boundary line: its ElementId and its two ends in plan, feet. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BoundaryLine { + /// The line's ElementId. + pub id: u32, + /// One end. + pub from: (f64, f64), + /// The other end. + pub to: (f64, f64), +} + +/// The region of the plane around a point, bounded by lines. +#[derive(Debug, Clone, PartialEq)] +pub struct AreaFace { + /// The outer loop, counter-clockwise, without a repeated closing vertex. + pub outer: Vec<(f64, f64)>, + /// The loops directly inside it, clockwise, the largest first. + pub holes: Vec>, + /// The ElementIds of the lines that bound the region, its holes' too. + pub line_ids: BTreeSet, +} + +/// A straight line along an axis. +#[derive(Debug, Clone, Copy)] +struct AxisLine { + id: u32, + horizontal: bool, + fixed: f64, + low: f64, + high: f64, +} + +impl AxisLine { + fn new(line: &BoundaryLine) -> Option { + let ends = [line.from.0, line.from.1, line.to.0, line.to.1]; + if ends + .iter() + .any(|v| !v.is_finite() || v.abs() > MAX_COORDINATE_FEET) + { + return None; + } + let dx = (line.to.0 - line.from.0).abs(); + let dy = (line.to.1 - line.from.1).abs(); + if dx <= NODE_TOLERANCE_FEET && dy <= NODE_TOLERANCE_FEET { + return None; + } + if dy <= NODE_TOLERANCE_FEET { + Some(AxisLine { + id: line.id, + horizontal: true, + fixed: (line.from.1 + line.to.1) / 2.0, + low: line.from.0.min(line.to.0), + high: line.from.0.max(line.to.0), + }) + } else if dx <= NODE_TOLERANCE_FEET { + Some(AxisLine { + id: line.id, + horizontal: false, + fixed: (line.from.0 + line.to.0) / 2.0, + low: line.from.1.min(line.to.1), + high: line.from.1.max(line.to.1), + }) + } else { + None + } + } + + fn point_at(&self, along: f64) -> (f64, f64) { + if self.horizontal { + (along, self.fixed) + } else { + (self.fixed, along) + } + } +} + +/// Points of the arrangement, merged where they are within the tolerance. +#[derive(Default)] +struct Nodes { + points: Vec<(f64, f64)>, + cells: BTreeMap<(i64, i64), Vec>, +} + +impl Nodes { + fn cell(point: (f64, f64)) -> (i64, i64) { + ( + (point.0 / NODE_TOLERANCE_FEET).floor() as i64, + (point.1 / NODE_TOLERANCE_FEET).floor() as i64, + ) + } + + fn intern(&mut self, point: (f64, f64)) -> usize { + let (cx, cy) = Nodes::cell(point); + for x in cx - 1..=cx + 1 { + for y in cy - 1..=cy + 1 { + let Some(held) = self.cells.get(&(x, y)) else { + continue; + }; + for &index in held { + let other = self.points[index]; + if (other.0 - point.0).abs() <= NODE_TOLERANCE_FEET + && (other.1 - point.1).abs() <= NODE_TOLERANCE_FEET + { + return index; + } + } + } + } + let index = self.points.len(); + self.points.push(point); + self.cells.entry((cx, cy)).or_default().push(index); + index + } +} + +/// One closed walk along the arrangement's edges with the same region on its +/// left. +#[derive(Debug)] +struct Ring { + nodes: Vec, + polygon: Vec<(f64, f64)>, + bounds: [f64; 4], + area: f64, + ids: BTreeSet, + component: usize, + simple: bool, +} + +/// The planar arrangement of a set of area boundary lines. +#[derive(Debug)] +pub struct Arrangement { + points: Vec<(f64, f64)>, + degree: Vec, + rings: Vec, + /// The rings of positive area: the regions. + bounded: Vec, + /// The ring of negative area that walks round each connected set of + /// lines, by the set. + outer_of: BTreeMap, + /// The sets with more than one such ring. + unclear: BTreeSet, +} + +fn find(parent: &mut [usize], node: usize) -> usize { + let mut root = node; + while parent[root] != root { + root = parent[root]; + } + let mut at = node; + while parent[at] != root { + let next = parent[at]; + parent[at] = root; + at = next; + } + root +} + +fn signed_area(points: &[(f64, f64)]) -> f64 { + let mut sum = 0.0; + for (index, p) in points.iter().enumerate() { + let q = points[(index + 1) % points.len()]; + sum += p.0 * q.1 - q.0 * p.1; + } + sum / 2.0 +} + +/// Whether `point` lies within the tolerance of the segment `a` to `b`. +fn on_segment(a: (f64, f64), b: (f64, f64), point: (f64, f64)) -> bool { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let length_squared = dx * dx + dy * dy; + let t = if length_squared <= 0.0 { + 0.0 + } else { + (((point.0 - a.0) * dx + (point.1 - a.1) * dy) / length_squared).clamp(0.0, 1.0) + }; + let (cx, cy) = (a.0 + t * dx, a.1 + t * dy); + (point.0 - cx).hypot(point.1 - cy) <= NODE_TOLERANCE_FEET +} + +/// Whether `point` is inside the loop `ring`: `None` when it is on the loop. +fn inside(ring: &[(f64, f64)], point: (f64, f64)) -> Option { + let mut crossings = 0usize; + for (index, &a) in ring.iter().enumerate() { + let b = ring[(index + 1) % ring.len()]; + if on_segment(a, b, point) { + return None; + } + if (a.1 > point.1) != (b.1 > point.1) { + let x = a.0 + (point.1 - a.1) / (b.1 - a.1) * (b.0 - a.0); + if x > point.0 { + crossings += 1; + } + } + } + Some(!crossings.is_multiple_of(2)) +} + +/// [`inside`] for a ring, with a quick answer for a point outside its box. +fn inside_ring(ring: &Ring, point: (f64, f64)) -> Option { + let [min_x, min_y, max_x, max_y] = ring.bounds; + let reach = NODE_TOLERANCE_FEET * 2.0; + if point.0 < min_x - reach + || point.0 > max_x + reach + || point.1 < min_y - reach + || point.1 > max_y + reach + { + return Some(false); + } + inside(&ring.polygon, point) +} + +impl Arrangement { + /// The arrangement of `lines`, or `None` where they are not all straight + /// lines along the plan axes, are more than [`MAX_LINES`], or overlap. + pub fn new(lines: &[BoundaryLine]) -> Option { + if lines.is_empty() || lines.len() > MAX_LINES { + return None; + } + let mut axis = Vec::with_capacity(lines.len()); + for line in lines { + axis.push(AxisLine::new(line)?); + } + let mut cuts: Vec> = vec![Vec::new(); axis.len()]; + for (i, &a) in axis.iter().enumerate() { + for (j, &b) in axis.iter().enumerate().skip(i + 1) { + if a.horizontal == b.horizontal { + let overlap = a.high.min(b.high) - a.low.max(b.low); + if (a.fixed - b.fixed).abs() <= NODE_TOLERANCE_FEET + && overlap > NODE_TOLERANCE_FEET + { + return None; + } + continue; + } + let (h, v, hi, vi) = if a.horizontal { + (a, b, i, j) + } else { + (b, a, j, i) + }; + let (x, y) = (v.fixed, h.fixed); + if x >= h.low - NODE_TOLERANCE_FEET + && x <= h.high + NODE_TOLERANCE_FEET + && y >= v.low - NODE_TOLERANCE_FEET + && y <= v.high + NODE_TOLERANCE_FEET + { + cuts[hi].push(x); + cuts[vi].push(y); + } + } + } + + let cut_count: usize = cuts.iter().map(Vec::len).sum(); + if cut_count.saturating_add(axis.len().saturating_mul(2)) > MAX_NODES { + return None; + } + let mut nodes = Nodes::default(); + let mut edges: BTreeMap<(usize, usize), u32> = BTreeMap::new(); + for (line, cut) in axis.iter().zip(cuts.iter_mut()) { + cut.push(line.low); + cut.push(line.high); + cut.sort_by(|p, q| p.partial_cmp(q).unwrap_or(std::cmp::Ordering::Equal)); + let mut previous: Option = None; + for &along in cut.iter() { + let along = along.clamp(line.low, line.high); + let node = nodes.intern(line.point_at(along)); + if let Some(last) = previous { + if last != node { + let key = (last.min(node), last.max(node)); + if let Some(&held) = edges.get(&key) { + if held != line.id { + return None; + } + } + edges.insert(key, line.id); + } + } + previous = Some(node); + } + } + + let count = nodes.points.len(); + // Leave out the lines with a free end, and then those that the removal + // frees, by a worklist of points with one line left: each line is + // removed once, so the pass is linear in the lines. + let candidates: Vec<((usize, usize), u32)> = + edges.iter().map(|(&ends, &id)| (ends, id)).collect(); + let mut alive = vec![true; candidates.len()]; + let mut degree = vec![0usize; count]; + let mut incident: Vec> = vec![Vec::new(); count]; + for (index, &((u, v), _)) in candidates.iter().enumerate() { + degree[u] += 1; + degree[v] += 1; + incident[u].push(index); + incident[v].push(index); + } + let mut free: Vec = (0..count).filter(|&node| degree[node] == 1).collect(); + while let Some(node) = free.pop() { + if degree[node] != 1 { + continue; + } + let Some(&edge) = incident[node].iter().find(|&&index| alive[index]) else { + continue; + }; + alive[edge] = false; + let ((u, v), _) = candidates[edge]; + let other = if u == node { v } else { u }; + degree[node] -= 1; + degree[other] -= 1; + if degree[other] == 1 { + free.push(other); + } + } + let edge_list: Vec<(usize, usize, u32)> = candidates + .iter() + .zip(&alive) + .filter(|&(_, &kept)| kept) + .map(|(&((u, v), id), _)| (u, v, id)) + .collect(); + let origin = |half: usize| { + let (u, v, _) = edge_list[half / 2]; + if half.is_multiple_of(2) { u } else { v } + }; + let target = |half: usize| origin(half ^ 1); + let mut outgoing: Vec> = vec![Vec::new(); count]; + for half in 0..edge_list.len() * 2 { + outgoing[origin(half)].push(half); + } + let mut slot = vec![0usize; edge_list.len() * 2]; + for (node, halves) in outgoing.iter_mut().enumerate() { + let here = nodes.points[node]; + halves.sort_by(|&p, &q| { + let angle = |half: usize| { + let there = nodes.points[target(half)]; + (there.1 - here.1).atan2(there.0 - here.0) + }; + angle(p) + .partial_cmp(&angle(q)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + for (index, &half) in halves.iter().enumerate() { + slot[half] = index; + } + } + + let mut parent: Vec = (0..count).collect(); + for &(u, v, _) in &edge_list { + let ru = find(&mut parent, u); + let rv = find(&mut parent, v); + parent[ru] = rv; + } + + let mut visited = vec![false; edge_list.len() * 2]; + let mut rings = Vec::new(); + for start in 0..edge_list.len() * 2 { + if visited.get(start).copied().unwrap_or(true) { + continue; + } + let mut walk = Vec::new(); + let mut ids = BTreeSet::new(); + let mut at = start; + loop { + visited[at] = true; + walk.push(origin(at)); + ids.insert(edge_list[at / 2].2); + let here = target(at); + let around = &outgoing[here]; + let back = slot[at ^ 1]; + at = around[(back + around.len() - 1) % around.len()]; + if at == start || walk.len() > edge_list.len() * 2 { + break; + } + } + let points: Vec<(f64, f64)> = walk.iter().map(|&n| nodes.points[n]).collect(); + let distinct: BTreeSet = walk.iter().copied().collect(); + let component = find(&mut parent, walk[0]); + let bounds = points.iter().fold( + [ + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ], + |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)], + ); + rings.push(Ring { + area: signed_area(&points), + simple: distinct.len() == walk.len(), + nodes: walk, + polygon: points, + bounds, + ids, + component, + }); + } + + let degree = outgoing.iter().map(Vec::len).collect(); + let bounded = (0..rings.len()) + .filter(|&i| rings[i].area > MIN_RING_AREA) + .collect(); + let mut outer_of: BTreeMap = BTreeMap::new(); + let mut unclear = BTreeSet::new(); + for (index, ring) in rings.iter().enumerate() { + if ring.area < -MIN_RING_AREA && outer_of.insert(ring.component, index).is_some() { + unclear.insert(ring.component); + } + } + Some(Arrangement { + points: nodes.points, + degree, + rings, + bounded, + outer_of, + unclear, + }) + } + + /// The ring's corners: its nodes, less those where two lines continue + /// straight on. + fn corners(&self, ring: &Ring) -> Option> { + let near = |p: f64, q: f64| (p - q).abs() <= NODE_TOLERANCE_FEET; + let count = ring.nodes.len(); + let mut out = Vec::with_capacity(count); + for (index, &node) in ring.nodes.iter().enumerate() { + let a = self.points[ring.nodes[(index + count - 1) % count]]; + let b = self.points[node]; + let c = self.points[ring.nodes[(index + 1) % count]]; + let straight = (near(a.0, b.0) && near(b.0, c.0)) || (near(a.1, b.1) && near(b.1, c.1)); + if !(straight && self.degree[node] == 2) { + out.push(b); + } + } + (out.len() >= 3).then_some(out) + } + + /// The region around `point`: the smallest loop of lines that encloses it, + /// less the loops directly inside that. `None` where the point is on a + /// line, in no loop, or in two loops of one size, or where its loop or one + /// of its holes touches itself. + pub fn face_at(&self, point: (f64, f64)) -> Option { + if !point.0.is_finite() || !point.1.is_finite() { + return None; + } + let mut chosen: Option = None; + for &i in &self.bounded { + if inside_ring(&self.rings[i], point)? { + match chosen { + Some(held) if self.rings[held].area <= self.rings[i].area => { + if (self.rings[held].area - self.rings[i].area).abs() <= MIN_RING_AREA { + return None; + } + } + _ => chosen = Some(i), + } + } + } + let face = &self.rings[chosen?]; + if !face.simple { + return None; + } + + let mut holes: Vec<&Ring> = Vec::new(); + for (component, &outer_index) in &self.outer_of { + if *component == face.component { + continue; + } + if self.unclear.contains(component) { + return None; + } + let outer = &self.rings[outer_index]; + let probe = self.points[outer.nodes[0]]; + if !inside_ring(face, probe)? { + continue; + } + let mut parent: Option<&Ring> = None; + for &i in &self.bounded { + let ring = &self.rings[i]; + if ring.component == *component { + continue; + } + if inside_ring(ring, probe)? { + match parent { + Some(held) if held.area <= ring.area => {} + _ => parent = Some(ring), + } + } + } + if parent.is_some_and(|p| std::ptr::eq(p, face)) { + if !outer.simple { + return None; + } + holes.push(outer); + } + } + holes.sort_by(|p, q| { + q.area + .abs() + .partial_cmp(&p.area.abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let mut line_ids = face.ids.clone(); + let mut hole_corners = Vec::with_capacity(holes.len()); + for hole in &holes { + line_ids.extend(hole.ids.iter().copied()); + hole_corners.push(self.corners(hole)?); + } + Some(AreaFace { + outer: self.corners(face)?, + holes: hole_corners, + line_ids, + }) + } +} diff --git a/src/research/partition_area_records.rs b/src/research/partition_area_records.rs index 4a727fd9f..28b1846fa 100644 --- a/src/research/partition_area_records.rs +++ b/src/research/partition_area_records.rs @@ -30,21 +30,60 @@ //! filter as the rest (`ElementFilteringUtil.GetExportFilter` in revit-ifc, //! CLEANROOM.md source 5), and skips an Area with no location. //! +//! # The body (RE-317) +//! +//! Revit's export draws each area space as an `IfcExtrudedAreaSolid` of the +//! Area's boundary, from its Level up to the Level above, when both are +//! building stories. The boundary is not +//! in the Area. It is the region around the Area's point that the lines of +//! its area scheme on its Level enclose ([`crate::partition_area_boundaries`]), +//! and the point is two `f64`s at [`AREA_POINT_OFFSET`] in the payload of the +//! Area's data object (RE-153). On the BIM4LCA office all 30 Areas, as built +//! and at building permit, equal Revit's in area (within 0.1%) and in outline +//! (within 0.01 ft), and 29 of the 30 in the mesh's vertices +//! (`reports/element-framing/RE-317-area-boundaries.md`). +//! //! # Honesty //! -//! - No geometry is read. Revit's export draws each area space as an -//! `IfcExtrudedAreaSolid`. The Area's boundary is not decoded, so its -//! space has no body. +//! - An Area has a body only where its point lies in exactly one region of +//! its scheme's lines on its Level, the lines it names bound that region, +//! every line of the scheme on the Level is a straight line along the plan +//! axes and in the primary design option, and every declared area boundary +//! line of the file was read and placed on a Level. The lines its record +//! names must be declared lines of that scheme on that Level whose copies +//! agree. An Area whose region another placed Area of its scheme and Level +//! shares has none, whatever stops the other from being drawn. Otherwise it +//! has none, as before, and says why in [`AREA_BODY_REFUSAL_FIELD`]. +//! - A line in a non-primary design option makes its group unreadable: no area +//! boundary line of the office or of Autodesk's 2024 samples has a design +//! option, so how Revit treats one is not measured. +//! - The height is the distance to the Level above, where that Level and the +//! Area's own are building stories and no other Level lies between them or at +//! either elevation. Revit's exporter takes the Level's "story above" first +//! (`LEVEL_UP_TO_LEVEL`), which is not read, and ignores the Area's own +//! upper Level; a Level whose story above is not the next Level up is not +//! measured. An Area on the top storey has none. +//! - The base offset of the Area is read as zero, which is what the office +//! measures. +//! - An Area bounded partly by walls is not read: its point lies in no region +//! of the lines alone. //! - Revit 2024 only, the one release measured; elsewhere nothing is read //! (fail closed). use crate::RevitFile; +use crate::element_record_plan_profiles::{PLAN_PROFILE_SOURCE_FIELD, PlanProfile}; use crate::le_bytes; +use crate::partition_area_boundaries::{AreaFace, Arrangement, BoundaryLine}; +use crate::partition_design_options::DesignOptions; use crate::partition_element_records::{ - CATEGORY_OFFSET, CONTAINER_NONE, CONTAINER_OFFSET, PLACEMENT_KIND_INSTANCE, + CATEGORY_OFFSET, CLASS_TAG_OFFSET, CONTAINER_NONE, CONTAINER_OFFSET, PLACEMENT_KIND_INSTANCE, PLACEMENT_KIND_OFFSET, file_bbox_marker, frame_design_option, partition_record_chain, + scan_category_records_multi, +}; +use crate::partition_level_records::PartitionLevel; +use crate::partition_room_parameters::{ + DATA_OBJECT_HEADER, LatestCopy, data_objects, partition_number, }; -use crate::partition_room_parameters::{LatestCopy, partition_number}; use crate::walker::{DecodedElement, ElementProvenance, InstanceField}; use std::collections::{BTreeMap, BTreeSet}; @@ -57,6 +96,67 @@ pub const OST_AREAS: i64 = -2_003_200; /// The `m_source` of an Area read here. pub const AREA_RECORD_SOURCE: &str = "partition_area_record"; +/// Autodesk `BuiltInCategory.OST_AreaSchemes`: an area scheme. +pub const OST_AREA_SCHEMES: i64 = -2_003_201; + +/// Autodesk `BuiltInCategory.OST_AreaSchemeLines`: an area boundary line. +pub const OST_AREA_SCHEME_LINES: i64 = -2_000_079; + +/// Where the point of an Area is in the payload of its data object, two +/// `f64`s of plan feet (RE-317). +pub const AREA_POINT_OFFSET: usize = 107; + +/// The source of an Area's plan profile and of its body (RE-317). +pub const AREA_BOUNDARY_SOURCE: &str = "partition_area_boundary_lines"; + +/// The field of an Area that holds the elevation of its body's base, feet. +pub const AREA_BODY_BASE_FIELD: &str = "m_area_body_base"; + +/// The field of an Area that holds its body's height, feet. +pub const AREA_BODY_HEIGHT_FIELD: &str = "m_area_body_height"; + +/// How far a boundary line's height may lie from its Level's, feet. +pub const AREA_LEVEL_TOLERANCE_FEET: f64 = 1e-3; + +/// The least distance to the Level above that gives an Area a body, feet. +pub const AREA_MIN_HEIGHT_FEET: f64 = 1e-3; + +/// The lowest ElementId read as an area boundary line in an Area's record. +pub const AREA_LINE_ID_FLOOR: u32 = 1000; + +/// The field of an Area that holds why it has no body, where it has none. +pub const AREA_BODY_REFUSAL_FIELD: &str = "m_area_body_refusal"; + +/// An Area has no body because the Levels were not recovered. +pub const REFUSED_LEVELS: &str = "levels_not_recovered"; +/// A declared area boundary line was not read, or cannot be placed on a Level: +/// no Area of the file is drawn, since the line may be in any group. +pub const REFUSED_UNACCOUNTED: &str = "boundary_line_not_read"; +/// The file holds no readable area boundary line. +pub const REFUSED_NO_LINES: &str = "no_boundary_lines"; +/// The Area's data object holds no single point. +pub const REFUSED_NO_POINT: &str = "point_not_read"; +/// The Area's record does not name one Level and one area scheme. +pub const REFUSED_UNNAMED: &str = "level_or_scheme_not_named"; +/// A line the Area's record names is not a declared line of its scheme on its +/// Level that was read: it has no Level or scheme, its copies disagree, or it is +/// in a non-primary design option. +pub const REFUSED_LINES: &str = "named_lines_not_in_scheme_and_level"; +/// The Area's Level and the Level above it are not both building stories, or +/// another Level lies at the Area's. +pub const REFUSED_HEIGHT: &str = "next_storey_not_proven"; +/// The lines of the Area's scheme on its Level cannot be read as an arrangement +/// of straight lines along the axes, one of them may be missing, or one is in a +/// non-primary design option. +pub const REFUSED_GROUP: &str = "boundary_lines_not_readable"; +/// The Area's point lies in no single region of the lines. +pub const REFUSED_REGION: &str = "point_in_no_single_region"; +/// The lines the Area's record names do not bound its region. +pub const REFUSED_OFF_REGION: &str = "named_lines_not_on_region"; +/// Another placed Area of the same scheme and Level has the same region, +/// whether or not that Area is drawn. +pub const REFUSED_REDUNDANT: &str = "two_areas_one_region"; + /// One Area record as its latest partition holds it. struct AreaRecord { stream: String, @@ -71,14 +171,23 @@ impl PartialEq for AreaRecord { } } -/// Every declared Area's chain record, its latest partition's copy; an Area -/// whose copies in that partition differ is left out. -fn area_records( - rf: &mut RevitFile, - marker: &[u8; 8], - declared: &BTreeSet, -) -> BTreeMap { +/// What the chain records of a file hold that an Area's body depends on. +struct ChainCensus { + /// Every declared Area's chain record, its latest partition's copy; an + /// Area whose copies in that partition differ is left out. + areas: BTreeMap, + /// The ElementIds of the declared area schemes. + scheme_ids: BTreeSet, + /// The ElementIds of every declared area boundary line. + line_ids: BTreeSet, +} + +/// The chain records of the Areas, the area schemes and the area boundary +/// lines of a file. +fn area_records(rf: &mut RevitFile, marker: &[u8; 8], declared: &BTreeSet) -> ChainCensus { let mut found = LatestCopy::default(); + let mut schemes = BTreeSet::new(); + let mut line_ids = BTreeSet::new(); for stream in rf.partition_stream_names() { let Ok(inflated) = rf.inflated_partition(&stream) else { continue; @@ -89,10 +198,19 @@ fn area_records( let Ok(id) = u32::try_from(span.element_id) else { continue; }; - if !declared.contains(&id) - || le_bytes::u64_at(buf, span.start + CATEGORY_OFFSET).map(|c| c as i64) - != Some(OST_AREAS) - { + if !declared.contains(&id) { + continue; + } + let category = le_bytes::u64_at(buf, span.start + CATEGORY_OFFSET).map(|c| c as i64); + if category == Some(OST_AREA_SCHEMES) { + schemes.insert(id); + continue; + } + if category == Some(OST_AREA_SCHEME_LINES) { + line_ids.insert(id); + continue; + } + if category != Some(OST_AREAS) { continue; } found.offer( @@ -106,35 +224,515 @@ fn area_records( ); } } - found.into_values() + ChainCensus { + areas: found.into_values(), + scheme_ids: schemes, + line_ids, + } } -/// The one id of `level_ids` that `bytes` holds as a `u64` past the frame's -/// fixed head, or `None` for none or several. -fn named_level(bytes: &[u8], level_ids: &BTreeSet) -> Option { +/// Every id of `wanted` from `floor` up that `bytes` holds as a `u64` past the +/// frame's fixed head. +fn named_ids(bytes: &[u8], wanted: &BTreeSet, floor: u32) -> BTreeSet { let mut named = BTreeSet::new(); for at in CATEGORY_OFFSET..bytes.len().saturating_sub(7) { if let Some(id) = le_bytes::u64_at(bytes, at) .and_then(|v| u32::try_from(v).ok()) - .filter(|id| level_ids.contains(id)) + .filter(|id| *id >= floor && wanted.contains(id)) { named.insert(id); } } - let mut ids = named.into_iter(); + named +} + +/// The one id of `wanted` that `bytes` holds as a `u64` past the frame's +/// fixed head, or `None` for none or several. +fn named_one(bytes: &[u8], wanted: &BTreeSet) -> Option { + let mut ids = named_ids(bytes, wanted, 0).into_iter(); match (ids.next(), ids.next()) { (Some(id), None) => Some(id), _ => None, } } +/// One area boundary line as its record holds it. +#[derive(Debug, Clone, Copy, PartialEq)] +struct LineRecord { + from: (f64, f64), + to: (f64, f64), + height: f64, + key: (u32, u32), +} + +/// The area boundary lines of a file, and what could not be read of them. +#[derive(Default)] +struct BoundaryLines { + by_id: BTreeMap, + /// Lines whose copies in several partitions disagree. + conflicted: BTreeSet, + /// The height of every line that is in no group it can be named in, and + /// of both copies of a line whose copies disagree, feet: each could lie in + /// any group on its Level. + stray_heights: Vec, + /// The ids of each scheme's lines on each Level, in the order met. + groups: BTreeMap<(u32, u32), Vec>, + /// Every line id the scan returned a record for, whatever became of it. + seen: BTreeSet, + /// A line whose height is not a number: it can be placed on no Level. + unplaceable: bool, + /// The groups that hold a line in a non-primary design option. Revit's + /// handling of such a line is not measured (no area boundary line of the + /// office or of Autodesk's 2024 samples has a design option), so the group + /// is not read. + option_groups: BTreeSet<(u32, u32)>, +} + +/// What an Area's body is: its region, the base and the height. +struct AreaBody { + face: AreaFace, + base_feet: f64, + height_feet: f64, +} + +/// What is known of the file around its Areas. +struct Surroundings<'a> { + declared: &'a BTreeSet, + level_ids: &'a BTreeSet, + scheme_ids: &'a BTreeSet, + levels: &'a [PartitionLevel], +} + +/// The one Level and the one area scheme a line's reference list names. +fn line_key( + references: &[u64], + level_ids: &BTreeSet, + scheme_ids: &BTreeSet, +) -> Option<(u32, u32)> { + let named: BTreeSet = references + .iter() + .filter_map(|reference| u32::try_from(*reference).ok()) + .collect(); + let mut levels = named.intersection(level_ids); + let mut schemes = named.intersection(scheme_ids); + match (levels.next(), levels.next(), schemes.next(), schemes.next()) { + (Some(&level), None, Some(&scheme), None) => Some((level, scheme)), + _ => None, + } +} + +/// Every area boundary line of the file, from its element record. A line in +/// a non-primary design option is left out, as an element is (RE-40). +fn boundary_lines( + rf: &mut RevitFile, + revit_version: u32, + surroundings: &Surroundings<'_>, + options: &DesignOptions, +) -> BoundaryLines { + let mut out = BoundaryLines::default(); + let Ok(records) = scan_category_records_multi( + rf, + revit_version, + &[OST_AREA_SCHEME_LINES], + surroundings.declared, + ) else { + return out; + }; + for record in records { + out.seen.insert(record.element_id); + let [x0, y0, z0, x1, y1, z1] = record.bbox_feet; + let finite = record.bbox_feet.iter().all(|v| v.is_finite()); + let key = line_key( + &record.references, + surroundings.level_ids, + surroundings.scheme_ids, + ); + if record + .design_option + .is_some_and(|option| options.is_non_primary(option)) + { + if let Some(key) = key { + out.option_groups.insert(key); + } else if z0.is_finite() { + out.stray_heights.push(z0); + } else { + out.unplaceable = true; + } + continue; + } + let usable = finite + && (z1 - z0).abs() <= AREA_LEVEL_TOLERANCE_FEET + && record.is_placed_instance() + && !record.is_container_member(); + let (true, Some(key)) = (usable, key) else { + if z0.is_finite() { + out.stray_heights.push(z0); + } else { + out.unplaceable = true; + } + continue; + }; + let line = LineRecord { + from: (x0, y0), + to: (x1, y1), + height: z0, + key, + }; + match out.by_id.get(&record.element_id) { + None => { + out.by_id.insert(record.element_id, line); + out.groups.entry(key).or_default().push(record.element_id); + } + Some(held) if *held == line => {} + Some(held) => { + out.stray_heights.push(held.height); + out.stray_heights.push(line.height); + out.conflicted.insert(record.element_id); + } + } + } + out +} + +/// The point of each Area, from the payload of its data object: where the +/// object's class is the record's and every copy in the record's partition +/// gives the same point. +fn area_points( + rf: &mut RevitFile, + placed: &BTreeMap, +) -> BTreeMap { + let mut by_stream: BTreeMap<&str, BTreeSet> = BTreeMap::new(); + for (&id, record) in placed { + by_stream + .entry(record.stream.as_str()) + .or_default() + .insert(id); + } + let mut found: BTreeMap> = BTreeMap::new(); + for (stream, ids) in by_stream { + let Ok(inflated) = rf.inflated_partition(stream) else { + continue; + }; + let buf = inflated.bytes(); + for (at, object) in data_objects(buf) { + let Some(record) = placed.get(&object.element_id) else { + continue; + }; + if !ids.contains(&object.element_id) + || le_bytes::u16_at(&record.bytes, CLASS_TAG_OFFSET).map(u32::from) + != Some(object.class) + { + continue; + } + let start = at + DATA_OBJECT_HEADER + AREA_POINT_OFFSET; + if start + 16 > object.end { + continue; + } + let point = match ( + le_bytes::f64_at(buf, start), + le_bytes::f64_at(buf, start + 8), + ) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some((x, y)), + _ => None, + }; + match found.get(&object.element_id) { + Some(held) if *held != point => { + found.insert(object.element_id, None); + } + Some(_) => {} + None => { + found.insert(object.element_id, point); + } + } + } + } + found + .into_iter() + .filter_map(|(id, point)| Some((id, point?))) + .collect() +} + +/// The elevation of the Level `level` and the distance up to the Level above +/// it, feet, where both are building stories and no other Level lies at either +/// elevation or between them (RE-317). Revit's exporter takes an Area's height +/// from its Level's "story above" where that is a building story, and else +/// from the next building story (`LevelUtil.CalculateDistanceToNextLevel` in +/// revit-ifc); the "story above" is not read, so a Level above that is not +/// known to be the next building story proves nothing. +fn level_and_storey_height(levels: &[PartitionLevel], level: u32) -> Option<(f64, f64)> { + let base = levels + .iter() + .find(|candidate| candidate.element_id == level)?; + if base.building_story != Some(true) || !base.elevation_feet.is_finite() { + return None; + } + let mut next: Option<&PartitionLevel> = None; + for other in levels { + if other.element_id == level { + continue; + } + if !other.elevation_feet.is_finite() { + return None; + } + let gap = other.elevation_feet - base.elevation_feet; + if gap.abs() <= AREA_MIN_HEIGHT_FEET { + return None; + } + if gap < 0.0 { + continue; + } + match next { + Some(held) + if (other.elevation_feet - held.elevation_feet).abs() <= AREA_MIN_HEIGHT_FEET => + { + return None; + } + Some(held) if held.elevation_feet < other.elevation_feet => {} + _ => next = Some(other), + } + } + let next = next?; + (next.building_story == Some(true)).then_some(( + base.elevation_feet, + next.elevation_feet - base.elevation_feet, + )) +} + +/// The arrangement of the lines of one scheme on one Level, or `None` where a +/// line cannot be read or may be missing. +fn group_arrangement( + lines: &BoundaryLines, + key: (u32, u32), + base_feet: f64, +) -> Option { + if lines + .stray_heights + .iter() + .any(|height| (height - base_feet).abs() <= AREA_LEVEL_TOLERANCE_FEET) + { + return None; + } + if lines.option_groups.contains(&key) { + return None; + } + let ids = lines.groups.get(&key)?; + let mut group = Vec::with_capacity(ids.len()); + for id in ids { + let line = lines.by_id.get(id)?; + if lines.conflicted.contains(id) + || (line.height - base_feet).abs() > AREA_LEVEL_TOLERANCE_FEET + { + return None; + } + group.push(BoundaryLine { + id: *id, + from: line.from, + to: line.to, + }); + } + Arrangement::new(&group) +} + +/// The Areas of a file that have a body, and why the others have none. +#[derive(Default)] +struct AreaBodies { + drawn: BTreeMap, + refused: BTreeMap, +} + +/// An area scheme on a Level. +type GroupKey = (u32, u32); + +/// A scheme on a Level and the corners of a region in it, by their bits. +type RegionKey = (GroupKey, Vec<(u64, u64)>); + +/// Where an Area's point lies among the lines of its scheme on its Level. +struct Located { + key: GroupKey, + /// The region, or why there is none: the lines cannot be read, or the point + /// is in no single region of them. + face: Result, +} + +/// The elevation of the Level `level`, feet. +fn level_elevation(levels: &[PartitionLevel], level: u32) -> Option { + let elevation = levels + .iter() + .find(|candidate| candidate.element_id == level)? + .elevation_feet; + elevation.is_finite().then_some(elevation) +} + +/// The region of one Area, whatever else stops it from being drawn: its point +/// among the lines of its scheme on its Level. `Err` where it has no point, or +/// its Level or scheme is not named, or its Level's elevation is not known. +fn locate( + record: &AreaRecord, + point: Option<(f64, f64)>, + lines: &BoundaryLines, + surroundings: &Surroundings<'_>, + arrangements: &mut BTreeMap>, +) -> Result { + let point = point.ok_or(REFUSED_NO_POINT)?; + let (Some(level), Some(scheme)) = ( + named_one(&record.bytes, surroundings.level_ids), + named_one(&record.bytes, surroundings.scheme_ids), + ) else { + return Err(REFUSED_UNNAMED); + }; + let key = (level, scheme); + let base_feet = level_elevation(surroundings.levels, level).ok_or(REFUSED_HEIGHT)?; + let face = match arrangements + .entry(key) + .or_insert_with(|| group_arrangement(lines, key, base_feet)) + { + Some(arrangement) => arrangement.face_at(point).ok_or(REFUSED_REGION), + None => Err(REFUSED_GROUP), + }; + Ok(Located { key, face }) +} + +/// The body of one Area: its region, where the lines its record names are all +/// declared lines of its scheme on its Level, are not conflicted and bound that +/// region, from its Level up to the Level above (RE-317); or why it has none. +fn draw_area( + record: &AreaRecord, + located: &Located, + lines: &BoundaryLines, + known_lines: &BTreeSet, + surroundings: &Surroundings<'_>, +) -> Result { + let named_lines = named_ids(&record.bytes, known_lines, AREA_LINE_ID_FLOOR); + let named_in_group = named_lines.iter().all(|line| { + !lines.conflicted.contains(line) + && lines + .by_id + .get(line) + .is_some_and(|held| held.key == located.key) + }); + if named_lines.is_empty() || !named_in_group { + return Err(REFUSED_LINES); + } + let (base_feet, height_feet) = + level_and_storey_height(surroundings.levels, located.key.0).ok_or(REFUSED_HEIGHT)?; + let face = located.face.as_ref().map_err(|reason| *reason)?; + if !named_lines.is_subset(&face.line_ids) { + return Err(REFUSED_OFF_REGION); + } + Ok(AreaBody { + face: face.clone(), + base_feet, + height_feet, + }) +} + +/// The body of each of `placed` that has one, and the reason for each that has +/// none (RE-317). An Area of a file with a declared area boundary line that +/// was not read, or cannot be placed on a Level, has none, since that line may +/// belong to any group. An Area whose region another placed Area of its scheme +/// and Level shares has none, whatever stops the other from being drawn: +/// Revit's second, redundant Area has no area of its own. +fn area_bodies( + rf: &mut RevitFile, + revit_version: u32, + placed: &BTreeMap, + declared_lines: &BTreeSet, + surroundings: &Surroundings<'_>, + options: &DesignOptions, +) -> AreaBodies { + let mut out = AreaBodies::default(); + if placed.is_empty() { + return out; + } + let refuse_all = |out: &mut AreaBodies, reason: &'static str| { + for &id in placed.keys() { + out.refused.insert(id, reason); + } + }; + if surroundings.levels.is_empty() { + refuse_all(&mut out, REFUSED_LEVELS); + return out; + } + let lines = boundary_lines(rf, revit_version, surroundings, options); + if lines.unplaceable || declared_lines.iter().any(|id| !lines.seen.contains(id)) { + refuse_all(&mut out, REFUSED_UNACCOUNTED); + return out; + } + if lines.by_id.is_empty() { + refuse_all(&mut out, REFUSED_NO_LINES); + return out; + } + let points = area_points(rf, placed); + let mut arrangements: BTreeMap> = BTreeMap::new(); + let mut located: BTreeMap> = BTreeMap::new(); + for (&id, record) in placed { + let point = points.get(&id).copied(); + located.insert( + id, + locate(record, point, &lines, surroundings, &mut arrangements), + ); + } + // Each Area's region is located; the groups' arrangements are not needed + // again, so they go before the regions are compared. + drop(arrangements); + // The lines an Area's record may name: those the chain declares, and those + // the scan returned without a chain record of their own. + let known_lines: BTreeSet = declared_lines + .iter() + .copied() + .chain(lines.by_id.keys().copied()) + .collect(); + let mut sharing: BTreeMap> = BTreeMap::new(); + for (&id, place) in &located { + let Ok(place) = place else { + continue; + }; + let Ok(face) = &place.face else { + continue; + }; + let outline = face + .outer + .iter() + .map(|p| (p.0.to_bits(), p.1.to_bits())) + .collect(); + sharing.entry((place.key, outline)).or_default().push(id); + } + let redundant: BTreeSet = sharing + .values() + .filter(|ids| ids.len() > 1) + .flatten() + .copied() + .collect(); + for (&id, record) in placed { + let result = match located.get(&id) { + Some(Ok(place)) => draw_area(record, place, &lines, &known_lines, surroundings), + Some(Err(reason)) => Err(*reason), + None => Err(REFUSED_NO_POINT), + }; + match result { + Ok(_) if redundant.contains(&id) => { + out.refused.insert(id, REFUSED_REDUNDANT); + } + Ok(body) => { + out.drawn.insert(id, body); + } + Err(reason) => { + out.refused.insert(id, reason); + } + } + } + out +} + /// Every placed Area of a Revit 2024 file, as a `DecodedElement` of class -/// `Area` with its number, name and Level where they are read. Empty on -/// every other release. +/// `Area` with its number, name and Level where they are read, and its +/// boundary, base and height where they are proven (RE-317). Empty on every +/// other release. pub fn areas_from_partition_chain_records( rf: &mut RevitFile, revit_version: u32, level_ids: &BTreeSet, + levels: &[PartitionLevel], ) -> Vec { if !AREA_SUPPORTED_REVIT_VERSIONS.contains(&revit_version) { return Vec::new(); @@ -147,7 +745,9 @@ pub fn areas_from_partition_chain_records( Err(_) => return Vec::new(), }; let options = rf.memoised_design_options(); - let placed: BTreeMap = area_records(rf, &marker, &declared) + let census = area_records(rf, &marker, &declared); + let placed: BTreeMap = census + .areas .into_iter() .filter(|(_, record)| { le_bytes::u64_at(&record.bytes, CONTAINER_OFFSET) == Some(CONTAINER_NONE) @@ -160,10 +760,24 @@ pub fn areas_from_partition_chain_records( let ids: BTreeSet = placed.keys().copied().collect(); let entries = crate::partition_room_parameters::scan_room_parameter_entries(rf, revit_version, &ids); + let surroundings = Surroundings { + declared: &declared, + level_ids, + scheme_ids: &census.scheme_ids, + levels, + }; + let mut bodies = area_bodies( + rf, + revit_version, + &placed, + &census.line_ids, + &surroundings, + &options, + ); placed .into_iter() .map(|(id, record)| { - let level = named_level(&record.bytes, level_ids); + let level = named_one(&record.bytes, level_ids); let mut fields = vec![ ( "m_builtinCategory".into(), @@ -204,6 +818,41 @@ pub fn areas_from_partition_chain_records( InstanceField::String(number.clone()), )); } + if let Some(reason) = bodies.refused.get(&id) { + fields.push(( + AREA_BODY_REFUSAL_FIELD.into(), + InstanceField::String((*reason).into()), + )); + } + if let Some(body) = bodies.drawn.remove(&id) { + let profile = PlanProfile { + outer_xy: body.face.outer, + inner_xy: body.face.holes, + segment_ids: body.face.line_ids.into_iter().collect(), + pieces: Vec::new(), + void_openings: Vec::new(), + }; + for (name, mut value) in profile.fields() { + if name == PLAN_PROFILE_SOURCE_FIELD { + value = InstanceField::String(AREA_BOUNDARY_SOURCE.into()); + } + fields.push((name, value)); + } + fields.push(( + AREA_BODY_BASE_FIELD.into(), + InstanceField::Float { + value: body.base_feet, + size: 8, + }, + )); + fields.push(( + AREA_BODY_HEIGHT_FIELD.into(), + InstanceField::Float { + value: body.height_feet, + size: 8, + }, + )); + } DecodedElement { id: Some(id), class: "Area".into(), diff --git a/src/research/partition_schema_mvp.rs b/src/research/partition_schema_mvp.rs index 9b063cfbe..1022c2c9d 100644 --- a/src/research/partition_schema_mvp.rs +++ b/src/research/partition_schema_mvp.rs @@ -350,11 +350,15 @@ pub fn recover_partition_schema_mvp( attach_room_outlines(rf, revit_version, &mut out.rooms); // Their heights from their native records (RE-255). attach_room_heights(rf, revit_version, &mut out.rooms); - // --- Areas, which carry no record box (#763) --- + // --- Areas, which carry no record box (#763), and the bodies their + // boundary lines give them (RE-317) --- + let levels = crate::partition_level_records::recover_partition_levels(rf, revit_version) + .unwrap_or_default(); out.areas = crate::partition_area_records::areas_from_partition_chain_records( rf, revit_version, &level_ids, + &levels, ); // --- Other product categories from element records (RE-33) --- @@ -364,12 +368,10 @@ pub fn recover_partition_schema_mvp( // --- Base constraints of elements naming two Levels (RE-59), then // railings by their host and elements by the Level objects they name // (RE-60) --- - let level_elevations: BTreeMap = - crate::partition_level_records::recover_partition_levels(rf, revit_version) - .unwrap_or_default() - .iter() - .map(|level| (level.element_id, level.elevation_feet)) - .collect(); + let level_elevations: BTreeMap = levels + .iter() + .map(|level| (level.element_id, level.elevation_feet)) + .collect(); let mut record_backed = [ &mut out.walls, &mut out.columns, diff --git a/tools/re/area_boundaries_vs_ifc.py b/tools/re/area_boundaries_vs_ifc.py new file mode 100644 index 000000000..28f03b6e1 --- /dev/null +++ b/tools/re/area_boundaries_vs_ifc.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Match Revit's IfcSpace profile of each Area with what the file holds (RE-317). + +`probe_re317_area_boundaries` prints, for every Area of a Revit 2024 project, +the bytes of its chain record and data objects, where else its ElementId +appears, the chain records it names, and every area boundary line whose +element record decodes (category -2000079, bounding box, references). Revit's +export draws an Area as an `IfcExtrudedAreaSolid` of its boundary. For each +Area, found by Revit's GlobalId, this prints: + +- what Revit wrote: the profile class, its curve class, vertex count, holes, + the extrusion depth, the body's lowest height against the Area's Level, and + the gap from the Area's storey to the next storey above it; +- the area boundary lines that lie on the outline of Revit's body (both + ends of a diagonal of the line's bounding box within 0.01 ft of it), how + much of the outline's length they cover and whether the Area's own record + names them; +- every `f64` in the probe's byte dumps that equals a coordinate of Revit's + outline within 0.0002 ft, with the dump and offset it was found at; +- whether the two `f64`s at byte 107 of the Area's data object (the point + `partition_area_records` reads, `AREA_POINT_OFFSET`) lie inside Revit's + profile, its holes taken out. + +Usage: + python3 tools/re/area_boundaries_vs_ifc.py PROBE.txt REVIT.ifc [--list] + +Needs IfcOpenShell (the version tools/ci/requirements-ifc.txt pins), NumPy and Shapely. +""" + +import collections +import struct +import sys + +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.util.element +import ifcopenshell.util.placement +import ifcopenshell.util.unit +import numpy as np +import shapely +from shapely.geometry import LineString, Point, Polygon + +from ifc_common import FEET, site_frame + +ON_BOUNDARY = 0.01 +COORDINATE_TOLERANCE = 2e-4 + + +def read_probe(path): + """The probe's lines by kind: tab-separated fields after the first.""" + out = collections.defaultdict(list) + for line in open(path, encoding="utf-8", errors="replace"): + parts = line.rstrip("\n").split("\t") + if len(parts) > 1: + out[parts[0]].append(parts[1:]) + return out + + +def storey_of(space): + parent = ifcopenshell.util.element.get_aggregate(space) + while parent is not None and not parent.is_a("IfcBuildingStorey"): + parent = ifcopenshell.util.element.get_aggregate(parent) + return parent + + +def elevation_of(storey, scale): + return float(storey.Elevation) * scale / FEET + + +def surface_of(settings, frame, space): + """(top surface, lowest z, highest z) in Revit's internal feet.""" + shape = ifcopenshell.geom.create_shape(settings, space) + verts = np.array(shape.geometry.verts, dtype=float).reshape(-1, 3) + faces = np.array(shape.geometry.faces, dtype=int).reshape(-1, 3) + v = frame(verts) + a, b, c = v[faces[:, 0]], v[faces[:, 1]], v[faces[:, 2]] + normal = np.cross(b - a, c - a) + length = np.linalg.norm(normal, axis=1) + up = (length > 1e-12) & (normal[:, 2] > 0.9 * length) + triangles = [ + Polygon([tuple(p[:2]) for p in tri]).buffer(1e-7) for tri in zip(a[up], b[up], c[up]) + ] + surface = shapely.union_all(triangles) if triangles else Polygon() + return surface, float(v[:, 2].min()), float(v[:, 2].max()) + + +def profile_of(space, scale): + """What the body's extrusion is made of, and its depth in feet.""" + out = [] + if not space.Representation: + return out + for representation in space.Representation.Representations: + for item in representation.Items: + if not item.is_a("IfcExtrudedAreaSolid"): + continue + area = item.SweptArea + curve = getattr(area, "OuterCurve", None) + holes = len(getattr(area, "InnerCurves", None) or []) + points = None + if curve is not None and curve.is_a("IfcPolyline"): + points = len(curve.Points) + elif curve is not None and curve.is_a("IfcIndexedPolyCurve"): + points = len(curve.Points.CoordList) + out.append( + ( + area.is_a(), + curve.is_a() if curve is not None else "-", + points, + holes, + float(item.Depth) * scale / FEET, + ) + ) + return out + + +def curve_points(curve): + """The points of a polyline or indexed poly curve, and whether it holds an arc.""" + if curve.is_a("IfcPolyline"): + return [tuple(point.Coordinates) for point in curve.Points], False + if curve.is_a("IfcIndexedPolyCurve"): + points = [tuple(point) for point in curve.Points.CoordList] + arcs = any(segment.is_a("IfcArcIndex") for segment in curve.Segments or []) + return points, arcs + return [], True + + +def profile_points(space, scale, frame): + """(outer, inners, arcs) of the first extrusion's profile in Revit's internal + feet: each curve a list of (x, y), as written, through the extrusion's + position, the space's placement and the site's.""" + for representation in space.Representation.Representations if space.Representation else []: + for item in representation.Items: + if not item.is_a("IfcExtrudedAreaSolid"): + continue + area = item.SweptArea + if not area.is_a("IfcArbitraryClosedProfileDef"): + continue + matrix = ifcopenshell.util.placement.get_local_placement(space.ObjectPlacement) + matrix = matrix @ ifcopenshell.util.placement.get_axis2placement(item.Position) + out, arcs = [], False + curves = [area.OuterCurve] + list(getattr(area, "InnerCurves", None) or []) + for curve in curves: + points, has_arc = curve_points(curve) + arcs = arcs or has_arc + if not points: + out.append([]) + continue + local = np.array([[x, y, 0.0, 1.0] for x, y in points]) + world = (matrix @ local.T).T[:, :3] * scale + feet = frame(world) + out.append([(round(float(x), 6), round(float(y), 6)) for x, y, _ in feet]) + return out[0], out[1:], arcs + return [], [], False + + +def rings(surface): + parts = list(surface.geoms) if hasattr(surface, "geoms") else [surface] + out = [] + for part in parts: + if part.is_empty: + continue + out.append(list(part.exterior.coords)) + out.extend(list(ring.coords) for ring in part.interiors) + return out + + +def hex_blobs(probe, area_id, wanted): + """(kind, label, bytes) for the dumps that belong to an Area, and for the + chain records of what its record names.""" + out = [] + for kind in ("rec", "obj", "occ"): + for fields in probe[kind]: + if fields[0] != str(area_id): + continue + label = " ".join(fields[1:-1]) + out.append((kind, label, bytes.fromhex(fields[-1]))) + for fields in probe["chain"]: + if int(fields[0]) in wanted: + label = f"{fields[0]} cat {fields[1]} {fields[2]} {fields[3]}" + out.append(("chain", label, bytes.fromhex(fields[-1]))) + return out + + +def coordinate_hits(blob, targets): + """(offset, value, target) for each f64 of `blob`, at any byte offset, within + COORDINATE_TOLERANCE of a target.""" + hits = [] + for shift in range(8): + count = (len(blob) - shift) // 8 + if count <= 0: + continue + values = np.frombuffer(blob[shift : shift + 8 * count], dtype=" storey_elev + 1e-6] + gap = (above[0] - storey_elev) if above else None + surface, z_low, z_high = surface_of(settings, frame, space) + quantities = ifcopenshell.util.element.get_psets(space).get("Qto_SpaceBaseQuantities", {}) + height = quantities.get("Height") + if height is not None: + height = round(float(height) * scale / FEET, 4) + elev = None if level_elev == "-" else float(level_elev) + next_level = [e for e, _, _ in levels if elev is not None and e > elev + 1e-6] + level_gap = (next_level[0] - elev) if next_level else None + print( + f"{area_id} {number} '{name}' Level {level_id} at {level_elev}:" + f" Revit profile {profile}; body z {z_low:.4f} to {z_high:.4f}" + f" (lowest minus Level {None if elev is None else round(z_low - elev, 4)});" + f" depth minus next-Level gap {None if profile == [] or level_gap is None else round(profile[0][4] - level_gap, 4)};" + f" depth minus next-storey gap {None if profile == [] or gap is None else round(profile[0][4] - gap, 4)};" + f" Qto Height {height}" + ) + outer, inners, has_arc = profile_points(space, scale, frame) + print(f"profile\t{area_id}\t{has_arc}\t{outer}\t{inners}") + point = None + for obj in probe["obj"]: + if obj[0] == str(area_id) and len(obj[-1]) >= 2 * (107 + 16): + point = struct.unpack_from("<2d", bytes.fromhex(obj[-1]), 107) + if point is not None and outer: + inside = Polygon(outer, inners).contains(Point(point)) + print(f" point {point[0]:.3f} {point[1]:.3f} inside Revit's profile: {inside}") + counts["point inside Revit's profile" if inside else "point outside Revit's profile"] += 1 + outline = rings(surface) + if not outline: + counts["no top surface"] += 1 + continue + boundary = surface.boundary + perimeter = float(boundary.length) + print( + f" top surface {surface.area:.3f} ft2, perimeter {perimeter:.3f} ft," + f" rings {[len(r) - 1 for r in outline]}" + ) + on = lines_on(boundary, lines) + on_ids = {i for i, _, _, _ in on} + covered = sum(length for _, _, length, _ in on) + print( + f" lines on the outline: {len(on)} of {len(lines)}, length {covered:.3f} of {perimeter:.3f} ft;" + f" the Area's record names {sorted(line_refs)}, on the outline: {sorted(line_refs & on_ids)}" + ) + if on: + print(" " + ", ".join(f"{i}({c})" for i, c, _, _ in on[:24])) + targets = np.array(sorted({round(c, 6) for ring in outline for p in ring for c in p[:2]})) + for kind, label, blob in hex_blobs(probe, area_id, wanted): + for offset, value, target in coordinate_hits(blob, targets)[:6]: + print(f" coordinate {value:.6f} (outline {target:.6f}) in {kind} {label} at +{offset:#x}") + counts[f"coordinate hits in {kind}"] += 1 + if line_refs & on_ids: + counts["a named line is on the outline"] += 1 + if on: + counts["lines on the outline"] += 1 + for key in sorted(counts): + print(f" {counts[key]:>4} {key}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tools/re/plan_profiles_vs_ifc.py b/tools/re/plan_profiles_vs_ifc.py index 33137643a..f70931900 100644 --- a/tools/re/plan_profiles_vs_ifc.py +++ b/tools/re/plan_profiles_vs_ifc.py @@ -45,9 +45,11 @@ from shapely.geometry import Polygon -def tops(path, classes): +def tops(path, classes, extents=None): """Tag -> (upward plan area, upward-face vertices in plan), every - element with the Tag (every piece of a sketch) taken together.""" + element with the Tag (every piece of a sketch) taken together. Where + `extents` is a dict, it gets each Tag's lowest and highest vertex height, + feet, in Revit's internal frame.""" f = ifcopenshell.open(path) frame = site_frame(f) settings = ifcopenshell.geom.settings() @@ -74,6 +76,11 @@ def tops(path, classes): if not len(verts) or not len(faces): continue v = frame(verts) + if extents is not None: + low, high = float(v[:, 2].min()), float(v[:, 2].max()) + if tag in extents: + low, high = min(low, extents[tag][0]), max(high, extents[tag][1]) + extents[tag] = (low, high) a, b, c = v[faces[:, 0]], v[faces[:, 1]], v[faces[:, 2]] normal = np.cross(b - a, c - a) length = np.linalg.norm(normal, axis=1) diff --git a/tools/re/space_profiles_vs_ifc.py b/tools/re/space_profiles_vs_ifc.py index 677f6d8ea..8acddc96a 100644 --- a/tools/re/space_profiles_vs_ifc.py +++ b/tools/re/space_profiles_vs_ifc.py @@ -20,9 +20,11 @@ outline to the other's, which such a face does not move. `vertices` is the vertex-to-vertex distance. The lines print `outline`, `vertices` and `boundary` under those names, and `both` is area and `outline`; -- the same counts split by what the space is (a room, a family instance - written as a space) and where rvt-rs's outline came from (its stored solid, - or the record box); +- the lowest and the highest vertex of the two bodies, within 0.01 ft: the + base and the top of the space, so its height (`lowest and highest vertex`); +- the same counts split by what the space is (a room, an Area, a family + instance written as a space) and where rvt-rs's outline came from (its + stored solid, the record box, or an Area's boundary lines); - every pair that is off in area or outline, with the reason rvt-rs's own property set gives (`SpaceOutlineSource`, or the older `ProfileSource` and `ProfileResolved` of `RvtElementRecordGeometry`). @@ -55,6 +57,8 @@ SET = "RvtElementRecordGeometry" STORED_SOLID = "stored_solid" RECORD_BOX = "record_box" +AREA_BOUNDARY = "area_boundary" +NO_BODY = "none" ROOM_SOLID_SOURCE = "partition_room_solid_faces" @@ -80,6 +84,9 @@ def sources(path): # space has neither. named = "RoomNumber" in props or "RoomName" in props kind = "room" if named else "family instance" if props else "space" + # An Area is named as a room is; its marker tells it apart (RE-317). + if marker in (AREA_BOUNDARY, NO_BODY): + kind = "area" out[space.GlobalId] = (source, kind) return out @@ -111,6 +118,14 @@ def points(surface): ) +def height_gap(ours, revit): + """The larger of the distances between the lowest and the highest vertex of + the two bodies, feet; infinite where either is unknown.""" + if ours is None or revit is None: + return float("inf") + return max(abs(ours[0] - revit[0]), abs(ours[1] - revit[1])) + + def count_spaces(path): return len(ifcopenshell.open(path).by_type("IfcSpace")) @@ -157,7 +172,9 @@ def main(argv): print(__doc__, file=sys.stderr) return 2 ours_path, revit_path = args - ours, revit = plan.tops(ours_path, ["IfcSpace"]), plan.tops(revit_path, ["IfcSpace"]) + ours_z, revit_z = {}, {} + ours = plan.tops(ours_path, ["IfcSpace"], ours_z) + revit = plan.tops(revit_path, ["IfcSpace"], revit_z) ours_total, revit_total = count_spaces(ours_path), count_spaces(revit_path) held = sources(ours_path) @@ -181,6 +198,7 @@ def main(argv): "rel": abs(o_area - area) / area, "area": area, "o_area": o_area, + "z": height_gap(ours_z.get(key), revit_z.get(other)), "vertices": plan.spread(o_points, points), "outline": plan.outline_spread(o_points, surface, points, o_surface), "boundary": boundary_spread(o_surface, surface), @@ -228,6 +246,11 @@ def outline_ok(r): print(f"space profiles: vertices within 0.01 ft {n_vertices}, off {in_both - n_vertices}") print(f"space profiles: boundary within 0.01 ft {n_boundary}, off {in_both - n_boundary}") print(f"space profiles: area within 0.1% and outline within 0.01 ft {n_both}") + n_height = sum(r["z"] <= 0.01 for r in rows) + print( + f"space profiles: lowest and highest vertex within 0.01 ft {n_height}," + f" off {in_both - n_height}" + ) split = collections.defaultdict(lambda: [0, 0, 0, 0, 0]) for r in rows: counts = split[f"{r['kind']} {r['source'].split(':')[0]}"] @@ -244,6 +267,15 @@ def outline_ok(r): f" boundary within 0.01 ft {e}" ) + height_split = collections.defaultdict(lambda: [0, 0]) + for r in rows: + counts = height_split[f"{r['kind']} {r['source'].split(':')[0]}"] + counts[0] += 1 + counts[1] += r["z"] <= 0.01 + for source, (n, h) in sorted(height_split.items()): + label = source.replace("_", " ") + print(f"space profiles: rvt-rs {label} height: {n}, lowest and highest vertex within 0.01 ft {h}") + off = [r for r in rows if not (area_ok(r, 1e-3) and outline_ok(r))] shown = sorted(off, key=lambda r: (-r["rel"], -r["outline"])) extra = sorted(rows, key=lambda r: (-r["rel"], -r["outline"]))[:listing]