diff --git a/changelog.d/changed/1260-room-solids-resolved.md b/changelog.d/changed/1260-room-solids-resolved.md new file mode 100644 index 00000000..4fca28bd --- /dev/null +++ b/changelog.d/changed/1260-room-solids-resolved.md @@ -0,0 +1,30 @@ +- **A room whose stored solid the exact walk did not close is read again + from the faces its solid lists, and labelled apart.** The walk stopped at a + record longer than 0x200 bytes between two faces, walls on lines up to + 0.0015 ft apart left a step no face crosses, a column overlapping a wall + put a loop partly inside the wall's, and a solid ran past the 64 KB window. + The solid ends at the count of its list of objects and at a gap of 1,024 + bytes, so the faces of the element that follows are not read, and the work + is bounded (512 faces, 4,096 pieces; crafted solids take under 10 ms). + `SpaceOutlineSource` is `stored_solid_faces` where the faces' edges close as + the walk reads them, and `stored_solid_approximate` where a column overlaps + a wall (its area depends on whether Revit's export takes the column off), + an end of one wall edge lies on another, or the outline falls up to + 0.02 ft short of the record box; `stored_solid` + stays the exact walk's. The diagnostics count both + (`exported.resolved_outline_spaces`, `exported.approximate_outline_spaces`) + and the room schedule leaves an approximate room's area blank. On the + BIM4LCA office as built the 13 rooms that kept their box take an outline: + nine are `stored_solid_faces`, all at Revit's area to 0.1% (their boxes were + 3% to 67% over it), and four `stored_solid_approximate`, 0.45% to 0.68% + under Revit's floor area. At building permit 11 of 12 take an outline, all + `stored_solid_approximate`, ten at Revit's floor area to 0.1%; room 3095831 + keeps its box. 6 of the 8 rooms that kept their box do on a held-out Revit + 2021 project, all 6 at Revit's area. Measure's `space_profiles_vs_ifc` + compares the faces of Revit's mesh that point up, which read a room under a + stepped or sloped ceiling as a fraction of itself (room 1671995: 1218 of + 5177 ft²; RE-313's 66% room); the new `space_overlaps_vs_ifc` also compares + the floor, where all 443 office `stored_solid` room scores agree. The six + `rac_advanced` rooms with round columns, 49 of the 50 `rme` rooms and + Snowdon Towers' seven keep their box. Core Interior and RE1 Architecture are + unchanged. A resolved room carries no `NetVolume` (#1260, RE-316). diff --git a/docs/evidence.md b/docs/evidence.md index 1f7578c8..e612d6a0 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -75,7 +75,7 @@ On Revit 2024 and 2025 project files, rvt-rs reads each element from its partiti | Electrical and mechanical devices and equipment | `IfcAlarm` (fire alarm devices), `IfcElectricAppliance` (data devices), proxies (switches, sensors, receptacles, junction boxes, panels, mechanical equipment) | RE1 Electrical and Mechanical, every one Revit exports and nothing else; the symbols nested in switches and receptacles left out, as Revit leaves them out (RE-79) | | 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 does not close keeps its box | +| 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) | | 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) | diff --git a/docs/export-diagnostics.md b/docs/export-diagnostics.md index cf80eec6..2f2d2b01 100644 --- a/docs/export-diagnostics.md +++ b/docs/export-diagnostics.md @@ -203,6 +203,8 @@ 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.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`. | | `confidence.score` | number | Heuristic 0..1 readiness score for UI sorting and dashboards. Its element terms (elements, typed elements, geometry) are scaled by the exported share when `unexported_element_records` is non-zero. | | `confidence.unexported_element_records` | integer | Element records of a recovered category (walls, doors, windows, columns, floors, rooms and the RE-33 product categories) the partition scan found but could not export because no ElementId is attributable (RE-30). Non-zero means the model is incomplete. Zero is not a completeness claim. | diff --git a/docs/rooms.md b/docs/rooms.md index bcf4fb41..a859aed1 100644 --- a/docs/rooms.md +++ b/docs/rooms.md @@ -1,6 +1,6 @@ # 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); 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. ## Which `IfcSpace`s rvt-rs writes @@ -19,18 +19,21 @@ Read `RvtElementRecordGeometry.SpaceOutlineSource` on the `IfcSpace`: | Value | The body is | Its plan area is | |---|---|---| -| `stored_solid` | the outline of the room's stored solid, extruded to the room's height | Revit's room area: within 0.1% of Revit's export on 604 of the 605 `stored_solid` rooms scored below, the office counted at both stages (RE-313 §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 24 room scores that keep their box | +| `stored_solid` | the outline of the room's stored solid, extruded to the room's height, read by the exact walk of RE-101 | Revit's room area: within 0.1% of Revit's export on every `stored_solid` room scored below by the scorer's upward faces except one, the permit office's STAIRCASE room 2611271 at 66%, which by Revit's floor is within 0.1% like the others (221 of 221 as built, 222 of 222 at permit; RE-313 §2, §3, RE-316 §3) | +| `stored_solid_faces` | the outline of the room's stored solid, read from the faces its solid lists, up to the solid's own end, where the walk did not close them: their edges close as the walk reads them (RE-316) | Revit's room area: within 0.1% on 9 of 9 office rooms as built, by every metric. Bounded by the solid's list of objects and a gap, not by the walk, so a closed loop of another element's faces inside the room's box would be read as the room's: that is why it is labelled apart (RE-316 §2, §3) | +| `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 | | `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 first two 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 `ProfileResolved`. +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`. The export also counts the spaces drawn as their record's box (rooms and family instances alike), so an importer can check a whole file before reading a space: - `exported.bounding_box_spaces` in the diagnostics JSON (`rvt-ifc --diagnostics`), out of `exported.by_ifc_type.IFCSPACE`; -- a line in `rvt-ifc`'s summary: "N of M spaces are their record's box". +- `exported.resolved_outline_spaces` (`stored_solid_faces`) and `exported.approximate_outline_spaces` (`stored_solid_approximate`), the rooms whose outline was not read by the exact walk; +- a line in `rvt-ifc`'s summary for each: "N of M spaces are their record's box", "N rooms take an outline resolved from the faces up to their stored solid's end", "N rooms take an approximate outline". ## Which releases read a room's outline @@ -51,7 +54,7 @@ The export also counts the spaces drawn as their record's box (rooms and family - **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. +- **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. 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. @@ -59,26 +62,29 @@ 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 | 221 | 221 | 211 | 220 rooms, stored solid: 220, 220; 12 rooms, record box: 0, 0; 44 family instances, record box: 1, 1 | -| BIM4LCA office at building permit (2024) | 278 of Revit's 308 | 222 | 222 | 216 | 222 rooms, stored solid: 221, 221; 12 rooms, record box: 0, 0; 44 family instances, record box: 1, 1 | +| 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 | | 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) | | One held-out house (2026) | 12 of 13 | 10 | 0 | 0 | 12 rooms, stored solid: 10, 0 | | Held-out Gdynia (2021, Revit's IFC2X3 export) | 18 of 19 | 18 | 0 | 0 | 18 rooms, stored solid: 18, 0 | -| Held-out Warmond (2021, export passed through another tool) | 179 | 174 | 174 | 174 | 171 rooms, stored solid: 171, 171; 8 rooms, record box: 3, 3 | +| Held-out Warmond (2021, export passed through another tool) | 179 | 179 | 179 | 179 | 171 rooms, stored solid: 171, 171; 6 rooms, stored solid faces: 6, 6; 2 rooms, record box: 2, 2 (rectangles) | | Vitoria `ALD26 Before` (2018, Revit's IFC2X3 export; spent) | 73 | 72 | 72 | 71 | 73 rooms, stored solid: 73, 72 | +On Warmond, RE-316 closes 6 of the 8 rooms that kept their box (5 were off in area; one was a rectangle already equal to Revit's, whose vertices are no longer the box's: vertices within 0.01 ft 127 → 126); the 2 that keep their box are rectangles. Area within 0.1% goes from 174 to 179 of the 179 paired spaces (Measure run 41728355227, `main` at 7fd339e2 against this change, counts only; by Revit's floor too: 177 of 177 `stored_solid` and `stored_solid_faces` rooms). + 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). -Held-out figures are Measure run 41680717786 (`main` at 21cb2efd), 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. 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 whose stored solid does not close on the record's box (12 on each office file)** keep the box, and its area is wrong on all 12. +- **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). -- **One room at building permit** is read from its stored solid and is 66% off in area (172.77 against 103.90 ft²); its cause is not found (RE-313 §3). +- **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 @@ -87,7 +93,7 @@ What the misses are: - 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). - A room's `Height` and `NetVolume` as scored by `tools/re/qtos_vs_ifc.py`, not by this scorer. - Revit's phase filter: Revit leaves out a room by its phase parameter, which is not measured to be its created phase (docs/status.md). -- Why 12 of the BIM4LCA office's 234 rooms keep their box on each file: the RE-101 probe says their solids do not close on the record's box; what is different about those solids is not found. +- Rooms resolved as `stored_solid_faces` or `stored_solid_approximate` (RE-316) on any release but Revit 2024: the evidence is one building in two states and one held-out project. No face of another element inside the room's box is excluded by anything but the solid's list of objects and a gap of 1,024 bytes (RE-316 §2). The six `rac_advanced` rooms with round columns (read as arcs, they would close in every release of the Autodesk sample, but whether Revit takes a column off a room's area is not measured), 49 of the 50 `rme` rooms (stepped floors and ceilings) and Snowdon Towers' seven keep their box. ## Check it yourself diff --git a/docs/schemas/export-diagnostics.schema.json b/docs/schemas/export-diagnostics.schema.json index c39e45d9..199260c6 100644 --- a/docs/schemas/export-diagnostics.schema.json +++ b/docs/schemas/export-diagnostics.schema.json @@ -284,6 +284,16 @@ "type": "integer", "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." + }, + "resolved_outline_spaces": { + "type": "integer", + "minimum": 0, + "description": "Exported IfcSpaces that are 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 stored_solid_faces, RE-316): equal to Revit's area on every such room measured, bounded by the solid's list of objects and a gap, so a closed loop of another element's faces inside the room's box would be read as the room's." + }, + "approximate_outline_spaces": { + "type": "integer", + "minimum": 0, + "description": "Exported IfcSpaces that are rooms whose resolved outline is the boundary of a region, a column overlapping 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 stored_solid_approximate, RE-316): their area is approximate, up to 0.68% under Revit's floor area where Revit's export does not take the column off." } } }, diff --git a/docs/status.md b/docs/status.md index 4ace3d10..372c36a9 100644 --- a/docs/status.md +++ b/docs/status.md @@ -110,10 +110,10 @@ geometry (doors and windows are drawn from the mesh Revit saved for their display by default, and any other family instance with the experimental `--saved-meshes` option, RE-78), hip roofs over a T or U footprint, roofs sloped by several slope arrows of the configuration of puzzbobb's U, where Revit slopes the two long outer walls along the arms only (RE-307; no other roof of the configuration is measured), and roofs by extrusion outside Revit 2025, the top of walls Revit steps over a floor that covers them in part (29 of the BIM4LCA office's as built and 26 at permit, #1067), the boundaries of -rooms whose stored solid does not close and opening profiles are not -decoded, and rvt-rs does not edit Revit model data. +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). The rooms of Revit 2026 and 2027 are boxes until their stored solids are read (#1260, PR #1288). [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, 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. ## Finding history diff --git a/docs/support-matrix.json b/docs/support-matrix.json index 44465976..5ee68b9b 100644 --- a/docs/support-matrix.json +++ b/docs/support-matrix.json @@ -662,7 +662,7 @@ "name": "Element and room schedules as CSV (rvt-schedule, viewer, Python)", "status": "partial", "evidence": "tests/rvt_schedule_cli.rs (tier-1 shape; 2024_Core_Interior 970 elements / 360 walls / 132 hosted doors / 6 windows / 256 columns / 116 rooms); viewer Playwright download test", - "notes": "One row per decoded element or room, so coverage is exactly the decode coverage: typed rows on every release whose element records are read (Revit 2023 to 2025; 2015 to 2022, 2026 and 2027 experimentally), scaffold-only elsewhere. Unknown values are empty cells. A room's area is its outline's where the room's stored solid was read (RE-101), and empty where the room keeps its box.", + "notes": "One row per decoded element or room, so coverage is exactly the decode coverage: typed rows on every release whose element records are read (Revit 2023 to 2025; 2015 to 2022, 2026 and 2027 experimentally), scaffold-only elsewhere. Unknown values are empty cells. A room's area is its outline's where the room's stored solid was read (RE-101) or its solid's faces close as the walk reads them (RE-316), and empty where the room keeps its box or its outline is approximate (stored_solid_approximate).", "user_impact": "A spreadsheet of walls, doors, windows, columns, slabs and rooms without Revit, on files whose elements decode." }, { diff --git a/docs/supported-profile.md b/docs/supported-profile.md index c8981612..87f3fe2a 100644 --- a/docs/supported-profile.md +++ b/docs/supported-profile.md @@ -112,7 +112,7 @@ counts reported on 2026-11-02 and 2026-11-03 on 0.12.0): | Stairs | aggregates of flights, landings and supports, each stair's and flight's `PredefinedType` from its runs and landings; straight flights as their steps, monolithic ones included; riserless flights as their treads, spiral ones as their sector-shaped treads; a monolithic landing as its slab, and a landing that is one tread as that tread | Snowdon Towers 33 of 38 drawn flights equal to Revit's; on the BIM4LCA office (Revit 2024) all 35 flights have Revit's volume, its 34 straight ones and its spiral one, its 21 monolithic landings Revit's top and outline, and its tread landing Revit's body; on puzzbobb's Revit 2025 stair models every flight of models 13 to 16 has Revit's volume (RE-214, RE-217). A monolithic landing's underside is drawn flat where Revit's follows a flight's soffit, 100.4% to 104.7% of Revit's volume, and its `BodySource` says so; monolithic spiral runs and riserless runs with a nosing keep their box (#357) | | 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 rooms read from their stored solid as built (221 of 222 at building permit) at Revit's area and outline, while its 12 rooms that keep their box and its 44 family-instance spaces drawn as boxes almost never are (RE-313 §3); 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) | +| 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 | | 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/probes/examples/probe_re316_hostile_solid.rs b/probes/examples/probe_re316_hostile_solid.rs new file mode 100644 index 00000000..21596793 --- /dev/null +++ b/probes/examples/probe_re316_hostile_solid.rs @@ -0,0 +1,139 @@ +//! RE-316: how long the room outline readers take on a crafted solid. +//! +//! FACT under test: a partition is untrusted input, and the readers that +//! resolve a room's stored solid ([`outline_of_solids`], the walk of RE-101, +//! and [`resolved_outline_of_solids`], RE-316) do bounded work on any solid: +//! the walk reads no more than `SOLID_FACES_MAX` faces, the span no more than +//! the solid's list of objects counts and `RESOLVE_FACES_MAX`, and +//! `region_boundary` gives up before it builds more than `BOUNDARY_PIECES_MAX` +//! pieces however often its segments cross. +//! +//! The probe builds solids in memory, runs both readers on each and prints the +//! time they took and whether they gave an outline. No model is read. +//! +//! - `grid`: a square grid of full-length wall faces, `n` across and `n` down, +//! whose segments cross `n * n` times (512 faces at most); +//! - `collinear`: wall faces that overlap along one line; +//! - `rooms`: concentric squares, the shape of a room with holes; +//! - `unbounded list`: faces whose list of objects counts more than the reader +//! resolves; +//! - `no list`: faces with no list of objects. +//! +//! Usage: +//! cargo run --profile ci -p rvt-probes --example probe_re316_hostile_solid + +use std::time::Instant; + +use rvt::research::partition_room_boundaries::{ + FACE_TAG, ROOM_SOLID_TAG, outline_of_solids, resolved_outline_of_solids, +}; + +const HEIGHT: f64 = 10.0; + +fn put(buf: &mut Vec, values: &[f64]) { + for value in values { + buf.extend_from_slice(&value.to_le_bytes()); + } +} + +/// A wall face along `direction` (a unit vector in plan) from `from` for +/// `length` feet, from the floor to `HEIGHT`, in the 174-byte layout. +fn wall(from: (f64, f64), direction: (f64, f64), length: f64) -> Vec { + let mut face = Vec::with_capacity(174); + face.extend_from_slice(&FACE_TAG); + face.extend_from_slice(&[0xff; 16]); + put(&mut face, &[0.0, 0.0, HEIGHT, length]); + face.resize(85, 0xff); + face.push(1); + put(&mut face, &[from.0, from.1, 0.0]); + put(&mut face, &[0.0, 0.0, 1.0]); + put(&mut face, &[direction.0, direction.1, 0.0]); + face.resize(174, 0xff); + face +} + +/// A solid's bytes: the room's id, the solid tag, the two boxes' room, the +/// list of objects (`list` of them, or none) and `faces`. +fn solid(faces: &[Vec], list: Option) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&7u64.to_le_bytes()); + buf.extend_from_slice(&ROOM_SOLID_TAG); + buf.extend_from_slice(&[0u8; 96]); + if let Some(count) = list { + buf.extend_from_slice(&[0x04, 0xc0, 0x08, 0x00]); + buf.extend_from_slice(&count.to_le_bytes()); + buf.extend_from_slice(&4u32.to_le_bytes()); + for token in 0..count { + buf.extend_from_slice(&[0x97, 0x06]); + buf.extend_from_slice(&(token + 5).to_le_bytes()); + } + } + buf.extend_from_slice(&[0u8; 16]); + for face in faces { + buf.extend_from_slice(face); + } + buf.extend_from_slice(&[0u8; 64]); + buf +} + +fn run(name: &str, faces: &[Vec], list: Option, extent: f64) { + let buf = solid(faces, list); + let bbox = [0.0, 0.0, 0.0, extent, extent, HEIGHT]; + let started = Instant::now(); + let walked = outline_of_solids(&buf, &[0], bbox); + let walk_ms = started.elapsed().as_secs_f64() * 1000.0; + let started = Instant::now(); + let resolved = resolved_outline_of_solids(&buf, &[0], bbox); + let resolve_ms = started.elapsed().as_secs_f64() * 1000.0; + println!( + "{name}: {} faces, {} bytes; walk {walk_ms:.1} ms ({}), resolve {resolve_ms:.1} ms ({})", + faces.len(), + buf.len(), + if walked.is_some() { "outline" } else { "none" }, + if resolved.is_some() { + "outline" + } else { + "none" + }, + ); +} + +fn main() { + for n in [10usize, 40, 100, 256] { + let extent = n as f64 + 1.0; + let mut faces = Vec::new(); + for k in 0..n { + faces.push(wall((0.0, k as f64 + 0.5), (1.0, 0.0), extent)); + faces.push(wall((k as f64 + 0.5, 0.0), (0.0, 1.0), extent)); + } + run( + &format!("grid {n} x {n}"), + &faces, + Some(faces.len() as u32), + extent, + ); + } + let overlapping: Vec> = (0..512) + .map(|k| wall((k as f64 * 0.01, 0.0), (1.0, 0.0), 100.0)) + .collect(); + run("collinear 512", &overlapping, Some(512), 100.0); + let squares: Vec> = (0..64) + .flat_map(|k| { + let a = k as f64 * 0.5; + let side = 100.0 - 2.0 * a; + [ + wall((a, a), (1.0, 0.0), side), + wall((a + side, a), (0.0, 1.0), side), + wall((a + side, a + side), (-1.0, 0.0), side), + wall((a, a + side), (0.0, -1.0), side), + ] + }) + .collect(); + run("rooms 64 concentric squares", &squares, Some(256), 100.0); + run("unbounded list", &squares, Some(100_000), 100.0); + run("no list", &squares, None, 100.0); + let many: Vec> = (0..5000) + .map(|k| wall((0.0, k as f64 * 0.01), (1.0, 0.0), 100.0)) + .collect(); + run("5000 faces", &many, Some(512), 100.0); +} diff --git a/probes/examples/probe_re316_room_closure.rs b/probes/examples/probe_re316_room_closure.rs new file mode 100644 index 00000000..b7258a6c --- /dev/null +++ b/probes/examples/probe_re316_room_closure.rs @@ -0,0 +1,380 @@ +//! RE-316: which rooms whose stored solid did not close now give an outline, +//! and whether the file agrees. +//! +//! FACT under test: where the faces RE-101 walks do not close on a room's +//! record box, the faces up to the next solid's tag, each plan edge directed +//! so the room is on its left, wind around a region whose boundary is the room's +//! outline (`partition_room_boundaries::region_boundary`). +//! +//! For each room whose own partition holds a solid and is not left exactly as +//! RE-101 reads it, one line: the room, the +//! outline RE-101 gave (area, or none), the outline now given, the outline of +//! the walls that reach the ceiling instead, the box's area, and for each area +//! whether Revit's cache of computed room areas in the same partition holds it +//! (`u64 · ff×8 · u32 0 · i32 · f64`, RE-101 §4). The last lines count the +//! rooms by what changed, and whether the plan edges of every room RE-101 kept +//! are directed head to tail. +//! +//! Usage: +//! cargo run --profile ci -p rvt-probes --example probe_re316_room_closure -- MODEL.rvt + +use std::collections::BTreeMap; + +use rvt::RevitFile; +use rvt::research::element_record_plan_profiles::{PlanProfile, plan_profile_from_lines}; +use rvt::research::partition_room_boundaries::{ + BOX_TOLERANCE_FEET, FaceKind, OutlineAnchor, ROOM_STEP_TOLERANCE_FEET, + ROOM_VERTEX_TOLERANCE_FEET, ResolvedKind, SpanRead, outline_of_solids, region_boundary, + resolved_outline_of_solids, solid_faces, solid_headers, solid_span, span_outline_at, +}; +use rvt::walker::InstanceField; + +fn ring_area(ring: &[(f64, f64)]) -> f64 { + let n = ring.len(); + (0..n) + .map(|i| { + let (a, b) = (ring[i], ring[(i + 1) % n]); + a.0 * b.1 - b.0 * a.1 + }) + .sum::() + .abs() + / 2.0 +} + +fn area(profile: &PlanProfile) -> f64 { + let net = |outer: &[(f64, f64)], inner: &[Vec<(f64, f64)>]| { + ring_area(outer) - inner.iter().map(|r| ring_area(r)).sum::() + }; + net(&profile.outer_xy, &profile.inner_xy) + + profile + .pieces + .iter() + .map(|p| net(&p.outer_xy, &p.inner_xy)) + .sum::() +} + +/// How many pairs of `lines` cross at a point that is an end of neither, and +/// how many overlap along a line for more than 1e-5 ft. +fn crossing_pairs(lines: &[[f64; 4]]) -> (usize, usize) { + let (mut crossings, mut overlaps) = (0, 0); + for (index, a) in lines.iter().enumerate() { + for b in &lines[index + 1..] { + let (ax, ay) = (a[2] - a[0], a[3] - a[1]); + let (bx, by) = (b[2] - b[0], b[3] - b[1]); + let (alen, blen) = (ax.hypot(ay), bx.hypot(by)); + if alen < 1e-6 || blen < 1e-6 { + continue; + } + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let cross = ax * by - ay * bx; + if cross.abs() > 1e-9 * alen * blen { + let t = (dx * by - dy * bx) / cross; + let u = (dx * ay - dy * ax) / cross; + let (te, ue) = (1e-6 / alen, 1e-6 / blen); + if t > te && t < 1.0 - te && u > ue && u < 1.0 - ue { + crossings += 1; + } + } else if ((dx * ay - dy * ax) / alen).abs() <= 1e-5 { + let along = |x: f64, y: f64| ((x - a[0]) * ax + (y - a[1]) * ay) / alen; + let (s0, s1) = (along(b[0], b[1]), along(b[2], b[3])); + let (lo, hi) = (s0.min(s1).max(0.0), s0.max(s1).min(alen)); + if hi - lo > 1e-5 { + overlaps += 1; + } + } + } + } + (crossings, overlaps) +} + +fn snap(lines: &mut [[f64; 4]]) { + snap_within(lines, ROOM_VERTEX_TOLERANCE_FEET); +} + +fn snap_within(lines: &mut [[f64; 4]], tolerance: f64) { + let mut held: Vec<(f64, f64)> = Vec::new(); + for line in lines.iter_mut() { + for end in [0, 2] { + let point = (line[end], line[end + 1]); + let near = held + .iter() + .copied() + .find(|q| (q.0 - point.0).abs() <= tolerance && (q.1 - point.1).abs() <= tolerance); + match near { + Some(q) => (line[end], line[end + 1]) = q, + None => held.push(point), + } + } + } +} + +/// The areas Revit caches in a partition. +fn cached_areas(buf: &[u8]) -> Vec { + let pattern = [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 0]; + memchr::memmem::find_iter(buf, &pattern) + .filter_map(|at| { + let bytes: [u8; 8] = buf.get(at + 16..at + 24)?.try_into().ok()?; + let value = f64::from_le_bytes(bytes); + (value.is_finite() && value > 0.0 && value < 1.0e7).then_some(value) + }) + .collect() +} + +fn in_cache(cache: &[f64], value: f64) -> bool { + cache.iter().any(|c| (c - value).abs() <= 1e-5 * value) +} + +fn text(profile: &Option, cache: &[f64]) -> String { + match profile { + Some(p) => { + let a = area(p); + format!( + "{a:.3} ({})", + if in_cache(cache, a) { + "cached" + } else { + "not cached" + } + ) + } + None => "none".into(), + } +} + +fn main() -> anyhow::Result<()> { + let path = std::env::args().nth(1).expect("usage: MODEL.rvt"); + let mut rf = RevitFile::open(&path)?; + let version = rf.basic_file_info()?.version; + let rooms = rvt::research::partition_schema_mvp::recover_partition_schema_mvp( + &mut rf, + version, + rvt::walker::WalkerLimits::default(), + )? + .rooms; + let mut tally: BTreeMap<&str, usize> = BTreeMap::new(); + let (mut oriented_ok, mut oriented_total) = (0, 0); + let mut cached_new = (0, 0); + let mut cached_slack = (0, 0); + let mut kept_cache = (0, 0); + for room in &rooms { + let Some(id) = room.id else { continue }; + let float = |wanted: &str| { + room.fields.iter().find_map(|(name, value)| match value { + InstanceField::Float { value, .. } if name == wanted => Some(*value), + _ => None, + }) + }; + let stream = room + .fields + .iter() + .find_map(|(name, value)| match value { + InstanceField::String(s) if name == "m_source_stream" => Some(s.clone()), + _ => None, + }) + .unwrap_or_default(); + let (Some(cx), Some(cy), Some(z), Some(dx), Some(dy), Some(dz)) = ( + float("m_locationX"), + float("m_locationY"), + float("m_locationZ"), + float("m_bboxWidth"), + float("m_bboxDepth"), + float("m_bboxHeight"), + ) else { + continue; + }; + let bbox = [ + cx - dx / 2.0, + cy - dy / 2.0, + z, + cx + dx / 2.0, + cy + dy / 2.0, + z + dz, + ]; + let inflated = rf.inflated_partition(&stream)?; + let buf = inflated.bytes(); + let headers = solid_headers(buf, id); + let Some(&header) = headers.first() else { + continue; + }; + let mut lines: Vec<[f64; 4]> = solid_faces(buf, header) + .iter() + .filter_map(|f| f.plan_edge()) + .filter(|(_, bottom)| (bottom - bbox[2]).abs() <= BOX_TOLERANCE_FEET) + .map(|(edge, _)| edge) + .collect(); + snap(&mut lines); + let walked = plan_profile_from_lines(&lines).filter(|p| { + p.plan_extent_feet().is_some_and(|e| { + e.iter() + .zip([bbox[0], bbox[1], bbox[3], bbox[4]]) + .all(|(got, want)| (got - want).abs() <= BOX_TOLERANCE_FEET) + }) + }); + if walked.is_some() { + oriented_total += 1; + let mut balance: Vec<((i64, i64), i32)> = Vec::new(); + let key = |x: f64, y: f64| ((x * 1e4).round() as i64, (y * 1e4).round() as i64); + for l in &lines { + for (point, step) in [(key(l[0], l[1]), 1), (key(l[2], l[3]), -1)] { + match balance.iter_mut().find(|(k, _)| *k == point) { + Some(entry) => entry.1 += step, + None => balance.push((point, step)), + } + } + } + if balance.iter().all(|(_, b)| *b == 0) { + oriented_ok += 1; + } + } + let walk_all = outline_of_solids(buf, &headers, bbox); + let resolved = if walk_all.is_none() { + resolved_outline_of_solids(buf, &headers, bbox) + } else { + None + }; + let now = walk_all + .clone() + .or_else(|| resolved.as_ref().map(|(profile, _)| profile.clone())); + let exact = SpanRead { + arcs: false, + slack: 0.0, + }; + let ceiling = span_outline_at(buf, header, bbox, OutlineAnchor::Ceiling, exact); + let with_arcs = span_outline_at( + buf, + header, + bbox, + OutlineAnchor::Floor, + SpanRead { + arcs: true, + slack: 0.0, + }, + ); + let class = match (&walk_all, &resolved) { + (Some(_), _) => "kept", + (None, Some((_, ResolvedKind::Faces))) => "faces", + (None, Some((_, ResolvedKind::Approximate))) => "approximate", + (None, None) => "still box", + }; + *tally.entry(class).or_default() += 1; + if resolved.is_some() { + let mut edges: Vec<[f64; 4]> = solid_span(buf, header) + .iter() + .filter_map(|f| f.plan_edge()) + .filter(|(_, bottom)| (bottom - bbox[2]).abs() <= BOX_TOLERANCE_FEET) + .map(|(edge, _)| edge) + .collect(); + snap_within(&mut edges, ROOM_STEP_TOLERANCE_FEET); + let (crossings, overlaps) = crossing_pairs(&edges); + let shape = match plan_profile_from_lines(&edges) { + Some(p) => format!( + "loops close, {} holes, {} further pieces", + p.inner_xy.len(), + p.pieces.len() + ), + None => "loops do not close as the walk reads them".to_string(), + }; + println!( + "room {id}: {class}; {} edges, {shape}, {crossings} crossing pairs, {overlaps} overlapping pairs", + edges.len() + ); + } + let cache = cached_areas(buf); + if let Some(profile) = walk_all + .as_ref() + .filter(|p| (area(p) - dx * dy).abs() > 1e-3 * dx * dy || p.outer_xy.len() > 4) + { + kept_cache.1 += 1; + if in_cache(&cache, area(profile)) { + kept_cache.0 += 1; + } + } + if walked.is_some() && walk_all.is_some() { + continue; + } + let box_area = dx * dy; + if let Some((profile, kind)) = resolved.as_ref() { + let slot = if *kind == ResolvedKind::Faces { + &mut cached_new + } else { + &mut cached_slack + }; + slot.1 += 1; + if in_cache(&cache, area(profile)) { + slot.0 += 1; + } + } + if walk_all.is_none() && resolved.is_none() { + let faces = solid_span(buf, header); + let tolerance = BOX_TOLERANCE_FEET; + let strays = faces + .iter() + .filter(|f| match f.kind() { + FaceKind::Vertical { low, high } => { + low < bbox[2] - tolerance + || high > bbox[5] + tolerance + || f.plan_edge().is_some_and(|(e, _)| { + e[0].min(e[2]) < bbox[0] - tolerance + || e[0].max(e[2]) > bbox[3] + tolerance + || e[1].min(e[3]) < bbox[1] - tolerance + || e[1].max(e[3]) > bbox[4] + tolerance + }) + } + _ => false, + }) + .count(); + let cylinders = faces.iter().filter(|f| f.cylinder.is_some()).count(); + let mut floor_lines: Vec<[f64; 4]> = faces + .iter() + .filter_map(|f| f.plan_edge()) + .filter(|(_, bottom)| (bottom - bbox[2]).abs() <= BOX_TOLERANCE_FEET) + .map(|(edge, _)| edge) + .collect(); + snap_within(&mut floor_lines, ROOM_STEP_TOLERANCE_FEET); + let why = match region_boundary(&floor_lines) { + Ok(pieces) => match plan_profile_from_lines(&pieces) { + Some(profile) => format!( + "boundary of {} pieces, profile area {:.3}, extent minus box {:.4?}", + pieces.len(), + area(&profile), + profile.plan_extent_feet().map(|e| [ + e[0] - bbox[0], + e[1] - bbox[1], + e[2] - bbox[3], + e[3] - bbox[4] + ]) + ), + None => format!("boundary of {} pieces, no profile", pieces.len()), + }, + Err(failure) => format!("{failure:?}"), + }; + println!( + "room {id}: span faces {} (stray {strays}, cylinders {cylinders}), floor edges {}; {}", + faces.len(), + floor_lines.len(), + why.chars().take(600).collect::() + ); + } + println!( + "room {id} {stream} box {box_area:.3} ({}) walked {} now {} ceiling {} with arcs {} [{class}]", + if in_cache(&cache, box_area) { + "cached" + } else { + "not cached" + }, + text(&walked, &cache), + text(&now, &cache), + text(&ceiling, &cache), + text(&with_arcs, &cache), + ); + } + println!("release {version}: {} rooms, {tally:?}", rooms.len()); + println!( + "plan edges directed head to tail on {oriented_ok} of the {oriented_total} rooms RE-101 kept" + ); + println!( + "Revit's cached room areas (u64 · ff x8 · u32 0 · i32 · f64, RE-101 section 4) hold the outline's area on {} of {} non-rectangular rooms RE-101 kept (the baseline), on {} of {} exact span outlines and on {} of {} approximate ones", + kept_cache.0, kept_cache.1, cached_new.0, cached_new.1, cached_slack.0, cached_slack.1 + ); + Ok(()) +} diff --git a/probes/examples/probe_re316_room_native_faces.rs b/probes/examples/probe_re316_room_native_faces.rs new file mode 100644 index 00000000..58e74ed0 --- /dev/null +++ b/probes/examples/probe_re316_room_native_faces.rs @@ -0,0 +1,268 @@ +//! RE-316: the B-rep a room's native record holds, and the plan edges at its +//! floor. +//! +//! MEASURED NEGATIVE (Measure run 41673755000, RE-316 section 1): the native +//! record of a room that is left to its box holds two `Face` objects, the +//! floor and ceiling planes, with no trim loop, and no `Edge` or `EdgeLoop` +//! object, so it cannot give the outline the partition's faces do not. +//! +//! FACT under test: a room whose partition faces do not close into an outline +//! on its record box (RE-101) holds, in its native record, the same solid as +//! `Face`, `EdgeLoop` and `Edge` objects, and the edges that lie in the floor's +//! plane, with their ends evaluated on the plane of a face they border, close +//! into the outline. +//! +//! Output, per room that does not close: the classes the record holds, the +//! first `Face`, `Edge`, `EdgeLoop` and `Plane`, how many faces each surface +//! class has, and the plan edges at the box's floor with whether +//! `plan_profile_from_lines` closes them on the box. +//! +//! Usage: +//! cargo run --profile ci -p rvt-probes --example probe_re316_room_native_faces -- MODEL.rvt + +use std::collections::{BTreeMap, BTreeSet}; + +use rvt::RevitFile; +use rvt::research::element_record_plan_profiles::plan_profile_from_lines; +use rvt::research::native_document::{NATIVE_RELEASES, Options, extract_graphs}; +use rvt::research::native_parameters::ObjectGraph; +use rvt::research::native_saved_mesh::pointer; +use rvt::research::partition_room_boundaries::{BOX_TOLERANCE_FEET, room_outline}; +use rvt::walker::InstanceField; +use serde_json::Value; + +const FLOOR_TOLERANCE_FEET: f64 = 1e-3; + +fn short(value: &Value) -> String { + value.to_string().chars().take(1400).collect() +} + +fn triple(value: &Value) -> Option<[f64; 3]> { + let a = value.as_array()?; + Some([ + a.first()?.as_f64()?, + a.get(1)?.as_f64()?, + a.get(2)?.as_f64()?, + ]) +} + +fn pair(value: &Value) -> Option<[f64; 2]> { + let a = value.as_array()?; + Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]) +} + +/// The plane `face` lies on: origin, u direction, v direction. +fn plane_of(g: &ObjectGraph, face: usize) -> Option<([f64; 3], [f64; 3], [f64; 3])> { + let surface = pointer(g, face, &g.objects.get(face)?.fields["m_pSurf"]) + .ok() + .flatten()?; + let plane = g.objects.get(surface)?; + if plane.class_name != "Plane" { + return None; + } + Some(( + triple(&plane.fields["m_origin"])?, + triple(&plane.fields["m_xVec"])?, + triple(&plane.fields["m_yVec"])?, + )) +} + +/// The edge's points on the plane of the face on `side`, model feet. +fn edge_points(g: &ObjectGraph, edge: usize, side: usize) -> Option> { + let object = g.objects.get(edge)?; + let faces = object.fields["m_pFace"].as_array()?; + let face = pointer(g, edge, faces.get(side)?).ok().flatten()?; + let (origin, x, y) = plane_of(g, face)?; + let ends = object.fields["m_firstAndLastEdgePnts"].as_array()?; + if ends.len() != 2 { + return None; + } + let mut uvs = vec![pair(&ends[0]["uv"][side])?]; + for sample in object.fields["m_interiorEdgePnts"].as_array()? { + uvs.push(pair(&sample["uv"][side])?); + } + uvs.push(pair(&ends[1]["uv"][side])?); + Some( + uvs.iter() + .map(|uv| std::array::from_fn(|k| origin[k] + uv[0] * x[k] + uv[1] * y[k])) + .collect(), + ) +} + +fn describe(g: &ObjectGraph, id: u64, bbox: [f64; 6]) { + let mut classes: BTreeMap<&str, usize> = BTreeMap::new(); + for object in &g.objects { + *classes.entry(object.class_name.as_str()).or_default() += 1; + } + println!( + " native room {id}: objects {} classes {classes:?}", + g.objects.len() + ); + for wanted in ["Face", "Edge", "EdgeLoop", "Plane", "RoomElem"] { + if let Some(object) = g.objects.iter().find(|o| o.class_name == wanted) { + println!(" first {wanted}: {}", short(&object.fields)); + } + } + let mut surfaces: BTreeMap = BTreeMap::new(); + let mut horizontal_planes: Vec = Vec::new(); + for (index, object) in g.objects.iter().enumerate() { + if object.class_name != "Face" { + continue; + } + let kind = pointer(g, index, &object.fields["m_pSurf"]) + .ok() + .flatten() + .and_then(|s| g.objects.get(s)) + .map_or("none".to_string(), |s| s.class_name.clone()); + *surfaces.entry(kind).or_default() += 1; + if let Some((origin, x, y)) = plane_of(g, index) { + let nz = x[0] * y[1] - x[1] * y[0]; + if nz.abs() > 0.5 { + horizontal_planes.push(format!("z {:.4} nz {nz:.3}", origin[2])); + } + } + } + println!(" faces by surface class {surfaces:?}; horizontal planes {horizontal_planes:?}"); + let floor = bbox[2]; + let mut lines: Vec<[f64; 4]> = Vec::new(); + let (mut edges, mut at_floor, mut unreadable) = (0, 0, 0); + for (index, object) in g.objects.iter().enumerate() { + if object.class_name != "Edge" { + continue; + } + edges += 1; + let Some(points) = (0..2).find_map(|side| edge_points(g, index, side)) else { + unreadable += 1; + continue; + }; + if points + .iter() + .all(|p| (p[2] - floor).abs() <= FLOOR_TOLERANCE_FEET) + { + at_floor += 1; + for w in points.windows(2) { + lines.push([w[0][0], w[0][1], w[1][0], w[1][1]]); + } + } + } + let mut pieces = lines.clone(); + pieces.retain(|l| (l[0] - l[2]).abs() > 1e-9 || (l[1] - l[3]).abs() > 1e-9); + println!( + " edges {edges}, at the floor {at_floor}, unreadable {unreadable}, plan segments {} ({} not degenerate)", + lines.len(), + pieces.len() + ); + match plan_profile_from_lines(&pieces) { + Some(profile) => { + let extent = profile.plan_extent_feet(); + let wanted = [bbox[0], bbox[1], bbox[3], bbox[4]]; + let gap = extent.map(|e| std::array::from_fn::(|k| e[k] - wanted[k])); + let on_box = gap.is_some_and(|d| d.iter().all(|v| v.abs() <= BOX_TOLERANCE_FEET)); + let ring = |r: &[(f64, f64)]| { + let n = r.len(); + (0..n) + .map(|i| { + let (a, b) = (r[i], r[(i + 1) % n]); + a.0 * b.1 - b.0 * a.1 + }) + .sum::() + .abs() + / 2.0 + }; + let holes: f64 = profile.inner_xy.iter().map(|r| ring(r)).sum(); + println!( + " FLOOR-EDGES {}: area {:.3} (box {:.3}) holes {} pieces {} extent-minus-box {gap:.4?}", + if on_box { + "CLOSE ON THE BOX" + } else { + "close, extent differs" + }, + ring(&profile.outer_xy) - holes, + (bbox[3] - bbox[0]) * (bbox[4] - bbox[1]), + profile.inner_xy.len(), + profile.pieces.len() + 1 + ); + } + None => println!(" FLOOR-EDGES do not close"), + } +} + +fn main() -> anyhow::Result<()> { + let path = std::env::args().nth(1).expect("usage: MODEL.rvt"); + let mut rf = RevitFile::open(&path)?; + let version = rf.basic_file_info()?.version; + let rooms = rvt::research::partition_schema_mvp::recover_partition_schema_mvp( + &mut rf, + version, + rvt::walker::WalkerLimits::default(), + )? + .rooms; + let mut failing: BTreeMap = BTreeMap::new(); + for room in &rooms { + let Some(id) = room.id else { continue }; + let float = |wanted: &str| { + room.fields.iter().find_map(|(name, value)| match value { + InstanceField::Float { value, .. } if name == wanted => Some(*value), + _ => None, + }) + }; + let stream = room + .fields + .iter() + .find_map(|(name, value)| match value { + InstanceField::String(s) if name == "m_source_stream" => Some(s.clone()), + _ => None, + }) + .unwrap_or_default(); + let (Some(cx), Some(cy), Some(z), Some(dx), Some(dy), Some(dz)) = ( + float("m_locationX"), + float("m_locationY"), + float("m_locationZ"), + float("m_bboxWidth"), + float("m_bboxDepth"), + float("m_bboxHeight"), + ) else { + continue; + }; + let bbox = [ + cx - dx / 2.0, + cy - dy / 2.0, + z, + cx + dx / 2.0, + cy + dy / 2.0, + z + dz, + ]; + if room_outline(&mut rf, &stream, id, bbox)?.is_none() { + failing.insert(u64::from(id), bbox); + } + } + println!( + "release {version}: {} rooms, {} do not close from their partition faces", + rooms.len(), + failing.len() + ); + let options = Options { + selected_ids: failing.keys().copied().collect::>(), + releases: NATIVE_RELEASES, + ..Options::default() + }; + let mut seen = 0; + extract_graphs(&mut rf, &options, |record| { + let id = record.identity.element_id; + let Some(&bbox) = failing.get(&id) else { + return Ok(()); + }; + seen += 1; + println!( + "room {id} stream {} class {:?} status {} box {:.3?}", + record.source.stream, record.class_name, record.status, bbox + ); + match record.graph.as_ref() { + Some(g) => describe(g, id, bbox), + None => println!(" no graph: {:?}", record.diagnostic), + } + Ok(()) + })?; + println!("native records read for {seen} of {} rooms", failing.len()); + Ok(()) +} diff --git a/probes/examples/probe_re316_room_solid_breaks.rs b/probes/examples/probe_re316_room_solid_breaks.rs new file mode 100644 index 00000000..79c7c904 --- /dev/null +++ b/probes/examples/probe_re316_room_solid_breaks.rs @@ -0,0 +1,551 @@ +//! RE-316: why a room's stored solid does not close on its record box. +//! +//! FACT under test: for each room whose own partition holds a solid (RE-101) +//! and whose wall faces do not close into an outline spanning the record box, +//! the probe says what is missing. It reads the solid three ways and says for +//! each whether the plan edges close into an outline on the record box: +//! +//! - `chain`: the faces RE-101 reads, each within `FACE_GAP` bytes of the one +//! before, and the faces that reach the box's floor; +//! - `span-floor`: every face between the first one and the next solid's tag, +//! whatever the gap, that reaches the floor and lies inside the box; +//! - `span-ceiling`: the same, for the faces that reach the box's ceiling; +//! - `span-any`: every vertical face, whatever its heights. +//! +//! Output, one block per room: the solid's header and tag census, one `face` +//! line per face of the chain, the gaps between faces that are not the usual +//! width, the faces beyond the chain and before the next solid, the vertices +//! that do not have exactly two edges and the loops' containment, then one +//! `variant` line for each reading. The last line tallies the verdicts. +//! +//! Usage: +//! cargo run --profile ci -p rvt-probes --example probe_re316_room_solid_breaks -- MODEL.rvt + +use std::collections::BTreeMap; + +use rvt::RevitFile; +use rvt::research::element_record_plan_profiles::{inside, plan_profile_from_lines}; +use rvt::research::partition_room_boundaries::{ + BOX_TOLERANCE_FEET, FACE_GAP, FACE_TAG, FaceKind, ROOM_SOLID_TAG, ROOM_SOLID_WINDOW, + ROOM_VERTEX_TOLERANCE_FEET, RoomFace, face_at, room_outline, solid_headers, +}; +use rvt::walker::InstanceField; + +const FACE_LEN: usize = 158; +const MAX_FACE_LINES: usize = 40; +const MAX_VERTEX_LINES: usize = 12; +const MAX_ORPHAN_LINES: usize = 16; + +type Faces = Vec<(usize, RoomFace)>; + +#[derive(Clone, Copy, PartialEq)] +enum Anchor { + Floor, + Ceiling, + Any, +} + +fn solid_start(buf: &[u8], header: usize) -> Option { + let after_id = header.checked_add(8)?; + if buf.get(after_id..after_id + 4)? == ROOM_SOLID_TAG { + return Some(after_id + 4); + } + (buf.get(after_id..after_id + 8)? == [0xff, 0xff, 0xff, 0xff, 0x04, 0x90, 0x08, 0x00]) + .then_some(after_id + 8) +} + +fn hex(bytes: &[u8]) -> String { + bytes + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(" ") +} + +fn chain(buf: &[u8], start: usize) -> Faces { + let end = buf.len().min(start + ROOM_SOLID_WINDOW); + let Some(window) = buf.get(start..end) else { + return Vec::new(); + }; + let Some(first) = memchr::memmem::find_iter(window, &FACE_TAG) + .map(|offset| start + offset) + .find(|&at| face_at(buf, at).is_some()) + else { + return Vec::new(); + }; + let mut out = Vec::new(); + let mut at = first; + while let Some(face) = face_at(buf, at) { + out.push((at, face)); + let from = at + FACE_TAG.len(); + let Some(window) = buf.get(from..buf.len().min(at + FACE_GAP)) else { + break; + }; + let Some(next) = memchr::memmem::find_iter(window, &FACE_TAG) + .map(|offset| from + offset) + .find(|&next| face_at(buf, next).is_some()) + else { + break; + }; + at = next; + } + out +} + +/// Every valid face from `start` to the next solid's tag, and where that is. +fn span(buf: &[u8], start: usize) -> (Faces, usize) { + let window_end = buf.len().min(start + ROOM_SOLID_WINDOW); + let Some(window) = buf.get(start..window_end) else { + return (Vec::new(), start); + }; + let end = memchr::memmem::find(window, &ROOM_SOLID_TAG).map_or(window_end, |o| start + o); + let faces = memchr::memmem::find_iter(&buf[start..end], &FACE_TAG) + .map(|offset| start + offset) + .filter_map(|at| face_at(buf, at).map(|face| (at, face))) + .collect(); + (faces, end) +} + +fn kind_text(face: &RoomFace) -> String { + match face.kind() { + FaceKind::Horizontal(z) => format!( + "H z {z:.4} u {:.3?} v {:.3?} origin {:.3?}", + face.u, face.v, face.origin + ), + FaceKind::Vertical { low, high } => match face.plan_edge() { + Some(([x0, y0, x1, y1], bottom)) => format!( + "V ({x0:.4},{y0:.4})-({x1:.4},{y1:.4}) low {low:.4} high {high:.4} bottom {bottom:.4}" + ), + None => format!( + "V-NOEDGE low {low:.4} high {high:.4} u {:.4?} v {:.4?}", + face.u, face.v + ), + }, + FaceKind::Other => format!("O u {:.4?} v {:.4?}", face.u, face.v), + } +} + +fn snap(lines: &mut [[f64; 4]]) { + let mut held: Vec<(f64, f64)> = Vec::new(); + for line in lines.iter_mut() { + for end in [0, 2] { + let point = (line[end], line[end + 1]); + let near = held.iter().copied().find(|q| { + (q.0 - point.0).abs() <= ROOM_VERTEX_TOLERANCE_FEET + && (q.1 - point.1).abs() <= ROOM_VERTEX_TOLERANCE_FEET + }); + match near { + Some(q) => (line[end], line[end + 1]) = q, + None => held.push(point), + } + } + } +} + +struct Graph { + points: Vec<(f64, f64)>, + degree: Vec, + edges: Vec<(usize, usize)>, + collapsed: usize, +} + +fn graph(lines: &[[f64; 4]]) -> Graph { + let mut g = Graph { + points: Vec::new(), + degree: Vec::new(), + edges: Vec::new(), + collapsed: 0, + }; + for &[x0, y0, x1, y1] in lines { + let mut ends = [0usize; 2]; + for (slot, p) in ends.iter_mut().zip([(x0, y0), (x1, y1)]) { + *slot = match g.points.iter().position(|q| *q == p) { + Some(index) => index, + None => { + g.points.push(p); + g.degree.push(0); + g.points.len() - 1 + } + }; + } + if ends[0] == ends[1] { + g.collapsed += 1; + continue; + } + g.degree[ends[0]] += 1; + g.degree[ends[1]] += 1; + g.edges.push((ends[0], ends[1])); + } + g +} + +fn distance_to_segment(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> (f64, f64) { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len2 = dx * dx + dy * dy; + let t = if len2 > 0.0 { + (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0) + } else { + 0.0 + }; + let (qx, qy) = (a.0 + t * dx, a.1 + t * dy); + (((p.0 - qx).powi(2) + (p.1 - qy).powi(2)).sqrt(), t) +} + +#[allow(clippy::needless_range_loop)] +fn loops(g: &Graph) -> Vec> { + let mut at: Vec> = vec![Vec::new(); g.points.len()]; + for (index, &(a, b)) in g.edges.iter().enumerate() { + at[a].push(index); + at[b].push(index); + } + let mut used = vec![false; g.edges.len()]; + let mut out = Vec::new(); + for first in 0..g.edges.len() { + if used[first] { + continue; + } + used[first] = true; + let (start, mut current) = g.edges[first]; + let mut ring = vec![g.points[start]]; + while current != start { + ring.push(g.points[current]); + let Some(next) = at[current].iter().copied().find(|&e| !used[e]) else { + break; + }; + used[next] = true; + let (a, b) = g.edges[next]; + current = if a == current { b } else { a }; + } + out.push(ring); + } + out +} + +fn ring_area(ring: &[(f64, f64)]) -> f64 { + let n = ring.len(); + (0..n) + .map(|i| { + let (a, b) = (ring[i], ring[(i + 1) % n]); + a.0 * b.1 - b.0 * a.1 + }) + .sum::() + .abs() + / 2.0 +} + +/// The plan edges of `faces` under `anchor`, inside `bbox`, snapped. +fn lines_of(faces: &Faces, bbox: [f64; 6], anchor: Anchor, inside_box: bool) -> Vec<[f64; 4]> { + let tol = BOX_TOLERANCE_FEET; + let mut lines = Vec::new(); + for (_, face) in faces { + let FaceKind::Vertical { high, .. } = face.kind() else { + continue; + }; + let Some((edge, bottom)) = face.plan_edge() else { + continue; + }; + let reaches = match anchor { + Anchor::Floor => (bottom - bbox[2]).abs() <= tol, + Anchor::Ceiling => (high - bbox[5]).abs() <= tol, + Anchor::Any => true, + }; + let within = edge[0].min(edge[2]) >= bbox[0] - tol + && edge[0].max(edge[2]) <= bbox[3] + tol + && edge[1].min(edge[3]) >= bbox[1] - tol + && edge[1].max(edge[3]) <= bbox[4] + tol; + if reaches && (within || !inside_box) { + lines.push(edge); + } + } + snap(&mut lines); + lines +} + +/// Whether the lines close into an outline on `bbox`, and what stops them. +fn verdict(lines: &[[f64; 4]], bbox: [f64; 6], detail: &mut Vec) -> String { + let g = graph(lines); + let open = g.degree.iter().filter(|&&d| d != 2).count(); + let mut histogram: BTreeMap = BTreeMap::new(); + for &d in &g.degree { + *histogram.entry(d).or_default() += 1; + } + let profile = plan_profile_from_lines(lines); + let mut text = format!( + "edges {} collapsed {} degrees {histogram:?} open {open}", + g.edges.len(), + g.collapsed + ); + if open == 0 && !g.edges.is_empty() { + let rings = loops(&g); + text.push_str(&format!(" loops {}", rings.len())); + for (i, one) in rings.iter().enumerate() { + for (j, other) in rings.iter().enumerate() { + if i == j { + continue; + } + let within = one.iter().filter(|p| inside(other, **p)).count(); + let touching = one + .iter() + .filter(|p| { + other.iter().enumerate().any(|(k, &b)| { + distance_to_segment(**p, other[(k + 1) % other.len()], b).0 <= 1e-6 + }) + }) + .count(); + if within > 0 && within < one.len() { + detail.push(format!( + " loop {i} ({} points, area {:.3}) is partly inside loop {j} ({} points, area {:.3}): {within} points in, {touching} on its boundary", + one.len(), + ring_area(one), + other.len(), + ring_area(other) + )); + } + } + } + } + match profile { + Some(profile) => { + let extent = profile.plan_extent_feet(); + let wanted = [bbox[0], bbox[1], bbox[3], bbox[4]]; + let gap = extent.map(|e| { + [ + e[0] - wanted[0], + e[1] - wanted[1], + e[2] - wanted[2], + e[3] - wanted[3], + ] + }); + let holes: f64 = profile.inner_xy.iter().map(|r| ring_area(r)).sum(); + let on_box = gap.is_some_and(|d| d.iter().all(|v| v.abs() <= BOX_TOLERANCE_FEET)); + text.push_str(&format!( + " PROFILE area {:.3} extent-minus-box {:.4?} {}", + ring_area(&profile.outer_xy) - holes, + gap, + if on_box { "CLOSES" } else { "extent-differs" } + )); + if on_box { + format!("CLOSES; {text}") + } else { + format!("extent-differs; {text}") + } + } + None if open > 0 => format!("open; {text}"), + None => format!("no-profile; {text}"), + } +} + +fn describe(buf: &[u8], header: usize, bbox: [f64; 6]) -> (Vec, String) { + let mut out = Vec::new(); + let Some(start) = solid_start(buf, header) else { + out.push(" solid header unreadable".into()); + return (out, "unreadable".into()); + }; + let walk = chain(buf, start); + let (in_span, span_end) = span(buf, start); + let last_at = walk.last().map_or(0, |(at, _)| *at); + let first_at = walk.first().map_or(0, |(at, _)| *at); + out.push(format!( + " solid header {header} start {start} first face +{} last face +{} next solid tag +{} faces in the chain {} in the span {}", + first_at.wrapping_sub(start), + last_at.wrapping_sub(start), + span_end - start, + walk.len(), + in_span.len() + )); + if let Some(bytes) = buf.get(start..start + 160) { + out.push(format!(" header words: {}", hex(bytes))); + } + let mut previous = None; + for (index, (at, face)) in walk.iter().enumerate() { + let gap = previous.map_or(0, |p| at - p); + if index < MAX_FACE_LINES { + out.push(format!( + " face {index} @{} gap {gap} uv {:.4?} {}", + at - start, + face.uv_box, + kind_text(face) + )); + } + if previous.is_some() && ![170, 174, 206].contains(&gap) && index < MAX_FACE_LINES { + let from = previous.unwrap_or(0) + FACE_LEN; + let upto = (*at).min(from + 64); + if let Some(bytes) = buf.get(from..upto) { + out.push(format!( + " gap-bytes before face {index} ({gap}): {}", + hex(bytes) + )); + } + } + previous = Some(*at); + } + if walk.len() > MAX_FACE_LINES { + out.push(format!(" ... {} faces more", walk.len() - MAX_FACE_LINES)); + } + let beyond: Vec<&(usize, RoomFace)> = in_span.iter().filter(|(at, _)| *at > last_at).collect(); + for (at, face) in beyond.iter().take(MAX_ORPHAN_LINES).copied() { + out.push(format!( + " in the span beyond the chain: face @{} ({} past the last) uv {:.4?} {}", + at - start, + at - last_at, + face.uv_box, + kind_text(face) + )); + } + if beyond.len() > MAX_ORPHAN_LINES { + out.push(format!( + " ... {} faces more in the span", + beyond.len() - MAX_ORPHAN_LINES + )); + } + let heights: Vec = { + let mut seen: Vec = Vec::new(); + for (_, face) in &in_span { + if let Some((_, bottom)) = face.plan_edge() { + if !seen.iter().any(|h| (h - bottom).abs() <= 1e-3) { + seen.push(bottom); + } + } + } + seen.sort_by(f64::total_cmp); + seen + }; + out.push(format!( + " vertical faces' bottoms in the span: {heights:.4?}, box floor {:.4} ceiling {:.4}", + bbox[2], bbox[5] + )); + let mut detail = Vec::new(); + let chain_floor = { + let mut lines = Vec::new(); + for (_, face) in &walk { + if let Some((edge, bottom)) = face.plan_edge() { + if (bottom - bbox[2]).abs() <= BOX_TOLERANCE_FEET { + lines.push(edge); + } + } + } + snap(&mut lines); + lines + }; + let a = verdict(&chain_floor, bbox, &mut detail); + let b = verdict( + &lines_of(&in_span, bbox, Anchor::Floor, true), + bbox, + &mut detail, + ); + let c = verdict( + &lines_of(&in_span, bbox, Anchor::Ceiling, true), + bbox, + &mut detail, + ); + let d = verdict( + &lines_of(&in_span, bbox, Anchor::Any, true), + bbox, + &mut detail, + ); + for line in &detail { + out.push(line.clone()); + } + let word = |s: &str| s.split(';').next().unwrap_or("").to_string(); + out.push(format!(" variant chain-floor: {a}")); + out.push(format!(" variant span-floor: {b}")); + out.push(format!(" variant span-ceiling: {c}")); + out.push(format!(" variant span-any: {d}")); + let summary = format!( + "chain {} | span-floor {} | span-ceiling {} | span-any {}", + word(&a), + word(&b), + word(&c), + word(&d) + ); + let _ = MAX_VERTEX_LINES; + out.push(format!(" SUMMARY {summary}")); + (out, summary) +} + +fn main() -> anyhow::Result<()> { + let path = std::env::args().nth(1).expect("usage: MODEL.rvt"); + let mut rf = RevitFile::open(&path)?; + let version = rf.basic_file_info()?.version; + let rooms = rvt::research::partition_schema_mvp::recover_partition_schema_mvp( + &mut rf, + version, + rvt::walker::WalkerLimits::default(), + )? + .rooms; + let mut tally: BTreeMap = BTreeMap::new(); + let mut kept = 0; + let mut absent = 0; + for room in &rooms { + let Some(id) = room.id else { continue }; + let float = |wanted: &str| { + room.fields.iter().find_map(|(name, value)| match value { + InstanceField::Float { value, .. } if name == wanted => Some(*value), + _ => None, + }) + }; + let stream = room + .fields + .iter() + .find_map(|(name, value)| match value { + InstanceField::String(s) if name == "m_source_stream" => Some(s.clone()), + _ => None, + }) + .unwrap_or_default(); + let (Some(cx), Some(cy), Some(z), Some(dx), Some(dy), Some(dz)) = ( + float("m_locationX"), + float("m_locationY"), + float("m_locationZ"), + float("m_bboxWidth"), + float("m_bboxDepth"), + float("m_bboxHeight"), + ) else { + continue; + }; + let bbox = [ + cx - dx / 2.0, + cy - dy / 2.0, + z, + cx + dx / 2.0, + cy + dy / 2.0, + z + dz, + ]; + if room_outline(&mut rf, &stream, id, bbox)?.is_some() { + kept += 1; + continue; + } + let inflated = rf.inflated_partition(&stream)?; + let buf = inflated.bytes(); + let headers = solid_headers(buf, id); + if headers.is_empty() { + absent += 1; + continue; + } + println!( + "room {id} {stream} box [{:.4}, {:.4}, {:.4}, {:.4}, {:.4}, {:.4}] size {dx:.3} x {dy:.3} x {dz:.3} solids {}", + bbox[0], + bbox[1], + bbox[2], + bbox[3], + bbox[4], + bbox[5], + headers.len() + ); + let mut first_summary = String::new(); + for (index, &header) in headers.iter().enumerate() { + let (block, summary) = describe(buf, header, bbox); + if index == 0 { + first_summary = summary; + } + for line in block { + println!("{line}"); + } + } + *tally.entry(first_summary).or_default() += 1; + } + println!( + "release {version}: {} rooms, {kept} outlines kept, {absent} with no solid in their partition, verdicts of the rest: {tally:#?}", + rooms.len() + ); + Ok(()) +} diff --git a/probes/examples/probe_re316_solid_extent.rs b/probes/examples/probe_re316_solid_extent.rs new file mode 100644 index 00000000..e38fa507 --- /dev/null +++ b/probes/examples/probe_re316_solid_extent.rs @@ -0,0 +1,209 @@ +//! RE-316: where a room's stored solid ends. +//! +//! FACT under test: a room's solid lists its objects after its two boxes as a +//! counted list (`04 c0 08 00 · u32 count · u32 4 · count × (u16 class · u32 +//! token)`), and the count, or a gap no solid's own faces exceed, says where +//! the faces of the solid stop and another element's begin. +//! +//! One line per room whose own partition holds a solid: the list's count and +//! its first entries, the faces the walk reads (W) and those up to the next +//! solid's tag (S) with how many of each are horizontal, vertical and +//! cylindrical, the offsets of the first face, the last walked face, the last +//! face of the span and the next solid's tag from the solid's start, the +//! largest gap between faces of the walk and of the span, every gap in the +//! span above 210 bytes, and the gap from the last walked face to the next +//! valid face. +//! +//! Usage: +//! cargo run --profile ci -p rvt-probes --example probe_re316_solid_extent -- MODEL.rvt + +use rvt::RevitFile; +use rvt::research::partition_room_boundaries::{ + FACE_GAP, FACE_TAG, FaceKind, ROOM_SOLID_TAG, ROOM_SOLID_WINDOW, RoomFace, face_at, + room_outline, solid_headers, +}; +use rvt::walker::InstanceField; + +fn solid_start(buf: &[u8], header: usize) -> Option { + let after_id = header.checked_add(8)?; + if buf.get(after_id..after_id + 4)? == ROOM_SOLID_TAG { + return Some(after_id + 4); + } + (buf.get(after_id..after_id + 8)? == [0xff, 0xff, 0xff, 0xff, 0x04, 0x90, 0x08, 0x00]) + .then_some(after_id + 8) +} + +fn u32_at(buf: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(buf.get(at..at + 4)?.try_into().ok()?)) +} + +fn u16_at(buf: &[u8], at: usize) -> Option { + Some(u16::from_le_bytes(buf.get(at..at + 2)?.try_into().ok()?)) +} + +fn walk(buf: &[u8], first: usize) -> Vec<(usize, RoomFace)> { + let mut out = Vec::new(); + let mut at = first; + while let Some(face) = face_at(buf, at) { + out.push((at, face)); + let from = at + FACE_TAG.len(); + let Some(window) = buf.get(from..buf.len().min(at + FACE_GAP)) else { + break; + }; + let Some(next) = memchr::memmem::find_iter(window, &FACE_TAG) + .map(|offset| from + offset) + .find(|&next| face_at(buf, next).is_some()) + else { + break; + }; + at = next; + } + out +} + +fn tally(faces: &[(usize, RoomFace)]) -> (usize, usize, usize) { + let (mut h, mut v, mut c) = (0, 0, 0); + for (_, face) in faces { + if face.cylinder.is_some() { + c += 1; + } else { + match face.kind() { + FaceKind::Horizontal(_) => h += 1, + FaceKind::Vertical { .. } => v += 1, + FaceKind::Other => {} + } + } + } + (h, v, c) +} + +fn main() -> anyhow::Result<()> { + let path = std::env::args().nth(1).expect("usage: MODEL.rvt"); + let mut rf = RevitFile::open(&path)?; + let version = rf.basic_file_info()?.version; + let rooms = rvt::research::partition_schema_mvp::recover_partition_schema_mvp( + &mut rf, + version, + rvt::walker::WalkerLimits::default(), + )? + .rooms; + for room in &rooms { + let Some(id) = room.id else { continue }; + let float = |wanted: &str| { + room.fields.iter().find_map(|(name, value)| match value { + InstanceField::Float { value, .. } if name == wanted => Some(*value), + _ => None, + }) + }; + let stream = room + .fields + .iter() + .find_map(|(name, value)| match value { + InstanceField::String(s) if name == "m_source_stream" => Some(s.clone()), + _ => None, + }) + .unwrap_or_default(); + let (Some(cx), Some(cy), Some(z), Some(dx), Some(dy), Some(dz)) = ( + float("m_locationX"), + float("m_locationY"), + float("m_locationZ"), + float("m_bboxWidth"), + float("m_bboxDepth"), + float("m_bboxHeight"), + ) else { + continue; + }; + let bbox = [ + cx - dx / 2.0, + cy - dy / 2.0, + z, + cx + dx / 2.0, + cy + dy / 2.0, + z + dz, + ]; + let kept = room_outline(&mut rf, &stream, id, bbox)?.is_some(); + let inflated = rf.inflated_partition(&stream)?; + let buf = inflated.bytes(); + for (index, &header) in solid_headers(buf, id).iter().enumerate() { + let Some(start) = solid_start(buf, header) else { + continue; + }; + let window_end = buf.len().min(start + ROOM_SOLID_WINDOW); + let Some(window) = buf.get(start..window_end) else { + continue; + }; + let tag_at = memchr::memmem::find(window, &ROOM_SOLID_TAG).map(|o| start + o); + let span_end = tag_at.unwrap_or(window_end); + let valid: Vec<(usize, RoomFace)> = + memchr::memmem::find_iter(&buf[start..span_end], &FACE_TAG) + .filter_map(|o| face_at(buf, start + o).map(|f| (start + o, f))) + .collect(); + let Some(&(first, _)) = valid.first() else { + continue; + }; + let walked = walk(buf, first); + let (wh, wv, wc) = tally(&walked); + let (sh, sv, sc) = tally(&valid); + let list = memchr::memmem::find(&buf[start..first.min(start + 400)], &[4, 0xc0, 8, 0]) + .map(|o| start + o); + let (count, lead, entries) = match list { + Some(p) => { + let n = u32_at(buf, p + 4).unwrap_or(0); + let lead = u32_at(buf, p + 8).unwrap_or(0); + let mut e = Vec::new(); + for k in 0..(n as usize).min(3) { + let at = p + 12 + 6 * k; + e.push(format!( + "{:04x}:{}", + u16_at(buf, at).unwrap_or(0), + u32_at(buf, at + 2).unwrap_or(0) + )); + } + let last_at = p + 12 + 6 * (n as usize).saturating_sub(1); + e.push(format!( + "last {:04x}:{}", + u16_at(buf, last_at).unwrap_or(0), + u32_at(buf, last_at + 2).unwrap_or(0) + )); + (n as i64, lead as i64, e.join(" ")) + } + None => (-1, -1, String::new()), + }; + let gaps = |faces: &[(usize, RoomFace)]| -> Vec { + faces.windows(2).map(|w| w[1].0 - w[0].0).collect() + }; + let wgaps = gaps(&walked); + let sgaps = gaps(&valid); + let big: Vec = sgaps + .iter() + .enumerate() + .filter(|(_, g)| **g > 210) + .take(8) + .map(|(i, g)| format!("{i}:{g}")) + .collect(); + let last_walk = walked.last().map_or(0, |(a, _)| *a); + let after = valid + .iter() + .map(|(a, _)| *a) + .find(|a| *a > last_walk) + .map(|a| a - last_walk); + let last_span = valid.last().map_or(0, |(a, _)| *a); + println!( + "room {id} {stream} hdr {index} {} N {count} lead {lead} [{entries}] W {} (h{wh} v{wv} c{wc}) S {} (h{sh} v{sv} c{sc}) first +{} lastW +{} lastS +{} tag {} maxgapW {} maxgapS {} bigS [{}] afterW {:?} stray_to_end {}", + if kept { "KEPT" } else { "FAILING" }, + walked.len(), + valid.len(), + first - start, + last_walk - start, + last_span - start, + tag_at.map_or("none".to_string(), |t| format!("+{}", t - start)), + wgaps.iter().copied().max().unwrap_or(0), + sgaps.iter().copied().max().unwrap_or(0), + big.join(" "), + after, + span_end - start, + ); + } + } + Ok(()) +} diff --git a/probes/examples/probe_re316_solid_vertices.rs b/probes/examples/probe_re316_solid_vertices.rs new file mode 100644 index 00000000..fc7d3b02 --- /dev/null +++ b/probes/examples/probe_re316_solid_vertices.rs @@ -0,0 +1,192 @@ +//! RE-316: the topology a room's solid stores before its first face. +//! +//! MEASURED NEGATIVE (Measure run 41676245766, RE-316 section 1): no run of +//! three `f64` in a room's solid before its first face is a point of the +//! room's box other than the two boxes; what lies there is the list of the +//! solid's objects (`u16 class · u32 token`), whose count bounds the solid +//! (`probe_re316_solid_extent`). +//! +//! FACT under test: between a room's solid header (RE-101) and its first face +//! the solid stores its vertices as three consecutive `f64`, a model-space +//! point inside the room's record box, and the points at the box's floor give +//! the room's true outline where the planar faces' boxes do not. +//! +//! Output, per room whose planar faces do not close on its box: the solid's +//! start and first face, every offset between them where three consecutive +//! `f64` are a point inside the box (with the 24 bytes before it and the gap +//! from the point before), the points at the floor, and the 96 bytes after the +//! header's second box. +//! +//! Usage: +//! cargo run --profile ci -p rvt-probes --example probe_re316_solid_vertices -- MODEL.rvt + +use rvt::RevitFile; +use rvt::research::partition_room_boundaries::{ + FACE_TAG, ROOM_SOLID_TAG, ROOM_SOLID_WINDOW, face_at, room_outline, solid_headers, +}; +use rvt::walker::InstanceField; + +const MAX_ROOMS: usize = 14; +const MAX_POINTS: usize = 90; +const TOLERANCE: f64 = 1e-3; + +fn solid_start(buf: &[u8], header: usize) -> Option { + let after_id = header.checked_add(8)?; + if buf.get(after_id..after_id + 4)? == ROOM_SOLID_TAG { + return Some(after_id + 4); + } + (buf.get(after_id..after_id + 8)? == [0xff, 0xff, 0xff, 0xff, 0x04, 0x90, 0x08, 0x00]) + .then_some(after_id + 8) +} + +fn hex(bytes: &[u8]) -> String { + bytes + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(" ") +} + +fn f64_at(buf: &[u8], at: usize) -> Option { + let bytes: [u8; 8] = buf.get(at..at + 8)?.try_into().ok()?; + let value = f64::from_le_bytes(bytes); + value.is_finite().then_some(value) +} + +fn triple_at(buf: &[u8], at: usize) -> Option<[f64; 3]> { + Some([ + f64_at(buf, at)?, + f64_at(buf, at + 8)?, + f64_at(buf, at + 16)?, + ]) +} + +fn main() -> anyhow::Result<()> { + let path = std::env::args().nth(1).expect("usage: MODEL.rvt"); + let mut rf = RevitFile::open(&path)?; + let version = rf.basic_file_info()?.version; + let rooms = rvt::research::partition_schema_mvp::recover_partition_schema_mvp( + &mut rf, + version, + rvt::walker::WalkerLimits::default(), + )? + .rooms; + let mut shown = 0; + for room in &rooms { + let Some(id) = room.id else { continue }; + let float = |wanted: &str| { + room.fields.iter().find_map(|(name, value)| match value { + InstanceField::Float { value, .. } if name == wanted => Some(*value), + _ => None, + }) + }; + let stream = room + .fields + .iter() + .find_map(|(name, value)| match value { + InstanceField::String(s) if name == "m_source_stream" => Some(s.clone()), + _ => None, + }) + .unwrap_or_default(); + let (Some(cx), Some(cy), Some(z), Some(dx), Some(dy), Some(dz)) = ( + float("m_locationX"), + float("m_locationY"), + float("m_locationZ"), + float("m_bboxWidth"), + float("m_bboxDepth"), + float("m_bboxHeight"), + ) else { + continue; + }; + let bbox = [ + cx - dx / 2.0, + cy - dy / 2.0, + z, + cx + dx / 2.0, + cy + dy / 2.0, + z + dz, + ]; + if room_outline(&mut rf, &stream, id, bbox)?.is_some() { + continue; + } + let inflated = rf.inflated_partition(&stream)?; + let buf = inflated.bytes(); + let Some(&header) = solid_headers(buf, id).first() else { + continue; + }; + let Some(start) = solid_start(buf, header) else { + continue; + }; + let window_end = buf.len().min(start + ROOM_SOLID_WINDOW); + let first_face = memchr::memmem::find_iter(&buf[start..window_end], &FACE_TAG) + .map(|offset| start + offset) + .find(|&at| face_at(buf, at).is_some()); + let Some(first_face) = first_face else { + continue; + }; + if shown >= MAX_ROOMS { + break; + } + shown += 1; + println!( + "room {id} {stream} box [{:.4}, {:.4}, {:.4}, {:.4}, {:.4}, {:.4}] solid start {start} first face +{}", + bbox[0], + bbox[1], + bbox[2], + bbox[3], + bbox[4], + bbox[5], + first_face - start + ); + if let Some(bytes) = buf.get(start + 8 + 96..start + 8 + 96 + 96) { + println!(" after the two boxes: {}", hex(bytes)); + } + let inside = |p: [f64; 3]| { + p[0] >= bbox[0] - TOLERANCE + && p[0] <= bbox[3] + TOLERANCE + && p[1] >= bbox[1] - TOLERANCE + && p[1] <= bbox[4] + TOLERANCE + && p[2] >= bbox[2] - TOLERANCE + && p[2] <= bbox[5] + TOLERANCE + }; + let mut points: Vec<(usize, [f64; 3])> = Vec::new(); + let mut at = start; + while at + 24 <= first_face { + match triple_at(buf, at) { + Some(p) if inside(p) && p.iter().any(|v| v.abs() > 1e-9) => { + points.push((at, p)); + at += 24; + } + _ => at += 1, + } + } + let at_floor: Vec<&(usize, [f64; 3])> = points + .iter() + .filter(|(_, p)| (p[2] - bbox[2]).abs() <= TOLERANCE) + .collect(); + println!( + " points inside the box {} (at the floor {})", + points.len(), + at_floor.len() + ); + let mut previous = start; + for (index, (offset, p)) in points.iter().enumerate().take(MAX_POINTS) { + let context = buf + .get(offset.saturating_sub(24)..*offset) + .map_or(String::new(), hex); + println!( + " point {index} @{} (+{} from the last) [{:.4}, {:.4}, {:.4}] before: {context}", + offset - start, + offset - previous, + p[0], + p[1], + p[2] + ); + previous = *offset + 24; + } + if points.len() > MAX_POINTS { + println!(" ... {} points more", points.len() - MAX_POINTS); + } + } + Ok(()) +} diff --git a/reports/element-framing/RE-316-room-solids-resolved.md b/reports/element-framing/RE-316-room-solids-resolved.md new file mode 100644 index 00000000..a49156cf --- /dev/null +++ b/reports/element-framing/RE-316-room-solids-resolved.md @@ -0,0 +1,212 @@ +# RE-316 — Rooms whose stored solid the walk did not close + +**Date:** 2026-11-03 +**Issues:** #1260 (rooms first); follows RE-101 and RE-102 (a room's outline from its stored solid's faces), RE-313 (every `IfcSpace` scored against Revit's export) and RE-314 +**Artefacts:** the BIM4LCA office as built and at building permit (Revit 2024) with Revit 24.0.5's IFC4 exports; Autodesk's `rac_advanced`, `rac_basic`, `rme_basic`, `rme_advanced` and Snowdon Towers sample projects (no Revit export of them is in CI); Core Interior and RE1 Architecture with Revit's exports; the R2025 models and the held-out models (counts only), all as Measure fetches them +**Probes:** `probes/examples/probe_re316_room_solid_breaks.rs`, `probe_re316_room_native_faces.rs` and `probe_re316_solid_vertices.rs` (the first finds what breaks; the other two are measured negatives, §1), `probe_re316_solid_extent.rs`, `probe_re316_room_closure.rs` and `probe_re316_hostile_solid.rs` +**Runs:** Measure [41670081379](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41670081379) and [41673400892](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41673400892) (what breaks), [41673755000](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41673755000) (the native record), [41676245766](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41676245766) (the topology before the first face), [41707078214](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41707078214) (where a solid ends, §2), [41710248227](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41710248227) (the rule, with the space scorer and the crafted solids), and the change against `main`, unfiltered, [41712392709](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41712392709) (§3) +**Result:** positive on the office, and the 323% that round 3 of the review found is the scorer's, not the outline's. Of the 13 rooms of the office as built that kept their record box, all 13 take an outline: 9 are `stored_solid_faces`, equal to Revit's area to 0.1% by every metric (their boxes were 3% to 67% over it), and 4 are `stored_solid_approximate`, 0.45% to 0.68% under Revit's floor area (1671995, 1820322, 1832504, 2066902). At building permit 11 of the 12 take an outline, all `stored_solid_approximate`: 10 equal to Revit's floor area to 0.1% and room 1671995 0.45% under; room 3095831 keeps its box. Room 1671995 is not 323% over Revit's area: that is the area of Revit's upward faces, a ceiling Revit's export draws mostly off the horizontal, 1218.33 ft² in 9 flat patches; Revit's floor under it is 5177.37 ft² and ours 5154.12 (§3). None of the four guards the review proposed separates it from the rooms that agree (§3), so none is applied; the label `stored_solid_approximate` says what the file cannot settle. + +## 1. Why the solids did not close + +RE-101 reads a room's stored solid as a run of planar faces, each within 0x200 bytes of the one before, and joins the plan edges of the faces that reach the room's floor. `probe_re316_room_solid_breaks` printed, for every room that kept its box, the faces of the walk, the faces beyond it and up to the next solid's tag, and the vertices that had fewer or more than two edges. Six things stop the walk or the loops: + +| Cause | Office as built | Office at permit | Autodesk samples | +|---|---|---|---| +| **A record longer than 0x200 bytes sits between two faces.** The walk stops there; the faces after it are the room's own (a 626 byte record in room 1422302) | 1422302, 1444351, 1736726, 1832504 | 1436086, 1480292, 1743273, 1743715 | | +| **Two walls stand on lines 0.0007 to 0.0015 ft apart** and the room's faces leave a step with no face across it; the ends are further apart than the 1e-4 ft RE-101 joins (RE1 room 355008 had 3e-5) | 1383998, 1493531, 1681182, 1700966, 1736690, 2297732 | | | +| **A column overlaps a wall.** A face's box is its plane's, not its outline: a wall's face runs the whole wall and the column's footprint overlaps it, so a loop of the column is partly inside the wall's loop and RE-101 refuses it | 1820322, 1832504 (also), 2066902, 1671995 | 1736676, 1736690, 1736720, 1743273, 1743279, 1743309, 1743715 (also), 2302311, 1671995 | | +| **A solid longer than the 64 KB window**: room 1681182 has 115 faces and the last is 84 KB in | 1681182 | | | +| **Round columns.** A face whose record is 44 bytes longer than a plane's (202 bytes against 174 on 2024) is a cylinder's: the axis (a unit vector at right angles to the face's u and v) at +158, the radius at +182, and a uv box that is an angle and a distance along the axis. `face_at` read it as a plane and `kind()` calls it horizontal, so its arc was not an edge and the loops stayed open | | | `rac_advanced`, 6 of 91 rooms in every release 2016 to 2023: room 177330's arcs, worked out from the radius 0.4921 ft and the angles 0.2007 and 6.0825, end at (41.9941, -50.1639) and (41.9941, -50.3601), the ends of the planar necks between them and the wall, to the four decimals the probe prints | +| **Stepped floors and ceilings.** A room whose floor is raised (a 0.4921 ft step) has walls that start at two heights, a riser between them, and soffits that stop short of the ceiling; the walls that reach the floor do not enclose it | | | `rme_basic`, `rme_advanced`: 49 of 50 | + +Two readings were measured and are **negative**: + +- **The room's native record does not hold the solid** (`probe_re316_room_native_faces`, run 41673755000). On all 13 office rooms, 50 `rme` rooms and 6 `rac_advanced` rooms it holds two `Face` objects, the floor and ceiling planes, with no trim loop, and no `Edge` or `EdgeLoop` object. +- **The topology before the first face holds no vertices** (`probe_re316_solid_vertices`, run 41676245766). Between the header (`01 00 00 00 03 00 00 00` and the solid's box twice) and the first face (4,788 to 64,547 bytes later) the solid lists its objects as `u16 class · u32 token` pointers after a counted list tag (`04 c0 08 00 · u32 count`); no run of three `f64` that is a point of the room's box appears in it other than the two boxes. The list's count is what bounds the solid (§2). + +## 2. The rule + +**The walk comes first, per room.** A room none of whose solids the walk closes on the record box is the only room read again: `attach_room_outlines` calls the RE-101 reader over all of the room's solids, as before, and only if it gives nothing resolves them (`resolved_outline_of_solids`). A room RE-101 keeps is therefore read exactly as it was, with its `NetVolume` marker, whatever a second solid of the room holds. The cap on the faces the walk reads (512, the largest solid measured lists 136) is the one change to it. + +**Where a solid ends.** The span from the walk's stop to the next room solid's tag is not the solid: it runs on into the faces of other elements, and the first version of this change read them (113 of room 1671995's 327 faces lay outside its box, and a foreign face inside the box would have passed). A solid lists its objects after its two boxes, `04 c0 08 00 · u32 count · u32 4 · count × (u16 class · u32 token)`, and the count includes the faces `face_at` does not read, a solid's floor and ceiling among them. `probe_re316_solid_extent` (run 41707078214: the office both files, Core Interior, RE1, Autodesk's 2019 and 2024 samples, 1,597 solids, 1,328 of them closing under the walk): + +- the list is present on all 1,597 with the 4 after the count, the count is never below the faces the walk reads, and is at most 136; +- the count minus the walk's faces is 0 on 472 of the solids the walk closes, 1 on 456, 2 on 360, 3 on 15, 4 on 24 and 12 on one; +- on the 47 solids whose count equals the walk's faces and which have a valid face after the walk, that face is another element's, 6,826 bytes or more on (the nearest is the office as built's, then Core Interior's at 6,858); +- the faces that are a solid's own beyond the walk's 0x200 byte gap lie 592 to 732 bytes on (the office and Autodesk's samples; 908 and 1,406 on two of Snowdon Towers' unresolved rooms). + +So the span is the faces from the first, each within 0x400 bytes of the one before (`SPAN_FACE_GAP`), and no more than the list counts (`solid_object_count`). Where the count equals the walk's faces nothing is added. Where it is larger, at most the difference could be another element's, and only if it follows within 1,024 bytes of a solid's last face: no solid measured does that, but none is measured with a difference above 4 and a face within 6,826 bytes. That is why a span-read outline is not `stored_solid` (below). The rme room 576007, which has no Revit export, has a count of 21 and 20 faces in the walk and the span alike: its outline is its own faces'. + +**From faces to an outline.** The wall faces that reach the floor give plan edges, ends within 0.0025 ft joined: the steps measured are 0.0007 to 0.0015 ft and the shortest real edge 0.0052 ft; the value is chosen from these, not copied, since revit-ifc takes the short-curve tolerance from the Revit API at run time (`IFCProject.cs` sets it from `application.ShortCurveTolerance`) and has no constant. Two readings, in this order, each spanning the record box to 0.001 ft as RE-101's does: + +- **`stored_solid_faces`**: the edges close as the walk of RE-101 reads them (`plan_profile_from_lines`: every end shared by two edges, one outer loop, the loops inside it nested) and no edge crosses or overlaps another. A face of another element would have to close a loop of its own, nested and clear of every wall, inside the box and inside the gap and the count above, to be read. +- **`stored_solid_approximate`**: otherwise the outline is the boundary of the region the edges wind around (`region_boundary`). Each plan edge is directed so the room is on its left, which the face's own frame already does: on all 221 and 222 rooms of the office the walk keeps, Core Interior's 116, RE1's 11 and the 14 to 85 each the Autodesk samples keep, the directed edges join head to tail (run 41710248227). `region_boundary` cuts every segment where another crosses or touches it, reads the winding number to either side of each piece, keeps the pieces with the room on one side only, and gives up unless every point is left by one piece and entered by one. A column whose loop lies partly inside a wall's takes its footprint off the room where they overlap and adds nothing outside it. The outline may also fall up to 0.02 ft short of the box on a side, which Griffin chose (2026-11-03): a column's back stands 0.0164 ft behind the wall's face on three office rooms. + +The first version labelled every span-read room `stored_solid_faces` whatever its edges did; the split above is the narrowing the standing rule asks for. + +**What a resolved room says.** `SpaceOutlineSource`: + +| Value | Read | Area, against Revit's floor area (downward faces) | +|---|---|---| +| `stored_solid` | RE-101's walk closed on the box | as RE-313: 221 of 221 as built and 222 of 222 at permit within 0.1% (§3) | +| `stored_solid_faces` | the span's edges close as the walk reads them | within 0.1% on 9 of 9 (office as built) | +| `stored_solid_approximate` | the boundary of a region, a column taken off, or up to 0.02 ft short of the box | within 0.1% on 10 of 15 room scores (4 as built, 11 at permit), 0.45% to 0.68% under on the other 5 (four rooms; 1671995 in both files): Revit's export does not take the column off | + +The exports count them (`exported.resolved_outline_spaces`, `exported.approximate_outline_spaces`; `rvt-ifc` prints a line for each), and the room schedule gives an area for `stored_solid` and `stored_solid_faces` rooms and none for approximate ones. A solid with a cylinder is not resolved, and a resolved outline carries no prism marker, so no `NetVolume`. + +**Bounded work.** A partition is untrusted input. The walk reads at most 512 faces (a solid with more is left to its box); the span at most the list's count, never above 512; `region_boundary` takes at most 512 segments and gives up before it builds more than 4,096 pieces, however often they cross. `probe_re316_hostile_solid` builds crafted solids in memory and times both readers (run 41710248227, GitHub's runner): + +| Solid | Walk | Resolve | +|---|---:|---:| +| grid 10 x 10 (20 faces) | 0.0 ms | 0.1 ms | +| grid 40 x 40 (80 faces, 1,600 crossings) | 0.0 ms | 9.1 ms | +| grid 100 x 100 (200 faces) | 0.1 ms | 0.4 ms | +| grid 256 x 256 (512 faces, 65,536 crossings) | 0.5 ms | 0.8 ms | +| 512 faces overlapping on one line | 0.5 ms | 0.0 ms | +| 64 concentric squares (256 faces) | 0.2 ms | 0.8 ms | +| a list counting 100,000 | 0.0 ms | 0.0 ms | +| no list | 0.2 ms | 0.0 ms | +| 5,000 faces | 0.1 ms | 6.1 ms | + +Every one gives its outline or none and takes under 10 ms. The review measured the first version, which took the faces up to the next solid's tag and cut them without a bound on the pieces, at about 10 s on about 500 crafted faces. + +## 3. Measured + +### The office, against Revit's own IFC4 export + +As built, the rooms that kept their box (ft²). Revit's area is its floor (the faces of its mesh that point down), which is the room's plan; its upward faces are in §3 below: + +| Room | Box | Revit | Outline | `SpaceOutlineSource` | Why the edges did not close as the walk reads them | +|---|---:|---:|---:|---|---| +| 1383998 | 1708.89 | 1020.46 | 1020.46 | `stored_solid_faces` | a step, 0 holes | +| 1422302 | 362.30 | 291.54 | 291.54 | `stored_solid_faces` | a record between faces, 1 hole | +| 1444351 | 362.31 | 291.56 | 291.56 | `stored_solid_faces` | a record between faces, 1 hole | +| 1493531 | 362.31 | 291.56 | 291.55 | `stored_solid_faces` | a step, 1 hole | +| 1681182 | 5963.96 | 3818.36 | 3818.36 | `stored_solid_faces` | a step, a long solid, 1 hole | +| 1700966 | 4774.18 | 3259.86 | 3259.85 | `stored_solid_faces` | a step, 1 hole | +| 1736690 | 2846.73 | 2049.24 | 2049.25 | `stored_solid_faces` | a step, 3 holes | +| 1736726 | 367.14 | 293.43 | 293.43 | `stored_solid_faces` | a record between faces, 1 hole | +| 2297732 | 135.82 | 131.76 | 131.76 | `stored_solid_faces` | a step | +| 1671995 | 5228.48 | 5177.37 | 5154.12 | `stored_solid_approximate` | a column overlaps a wall: 6 crossing pairs | +| 1820322 | 1882.11 | 1877.97 | 1865.97 | `stored_solid_approximate` | 9 crossing pairs | +| 1832504 | 465.18 | 440.31 | 437.31 | `stored_solid_approximate` | 2 crossing pairs | +| 2066902 | 1542.77 | 638.23 | 635.23 | `stored_solid_approximate` | 3 crossing pairs | + +The first nine have no edge crossing or overlapping another (`probe_re316_room_closure`, run 41728640203) and Revit's area to 0.1% by the upward faces, the floor and every face. The four approximate rooms are 0.45% (1671995: 23.25 ft²), 0.64% (1820322: 12.00), 0.68% (1832504: 3.00) and 0.47% (2066902: 3.00) under Revit's floor area, the 3.0 ft² being a column's 3.001. Revit's export does not take those columns off the room, where it takes off the 1.722 ft² columns of eight rooms at permit. + +At building permit 11 rooms are `stored_solid_approximate`: 1671995 (6 crossing pairs; 5153.60 against Revit's floor of 5176.85, 0.45% under); 1736676, 1736690, 1736720, 1743273, 1743279, 1743309, 1743715 and 2302311 (1 to 3 pairs of edges overlapping along a line, no crossing: the columns); and 1436086 and 1480292 (neither crossing nor overlap: an end lies on another edge). Ten have Revit's floor area to 0.1%: their boxes were 2.6% to 58% over it. Room 3095831 keeps its box: 14 ends at y = 35.269 ft, in pairs 0.656 and 1.312 ft apart down a 100 ft wall, are not joined, since the columns along it share the wall's line. + +Rooms per outline source (the export's own counts, [41728355227](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41728355227); the scorer pairs 276 of the as-built file's 308 spaces and 278 of the permit's): + +| | `stored_solid` | `stored_solid_faces` | `stored_solid_approximate` | `record_box` | family instances, `record_box` | +|---|---:|---:|---:|---:|---:| +| Office as built (234 rooms) | 221 | 9 | 4 | 0 | 44 | +| Office at permit (234 rooms) | 222 | 0 | 11 | 1 | 44 | + +`space_profiles_vs_ifc` (the existing scorer, which compares the faces of Revit's mesh that point up), `main` against this change: + +| | Area within 0.1% | `stored_solid` scored | `stored_solid_faces` scored, area within 0.1% | `stored_solid_approximate` scored, area within 0.1% | Room, record box | +|---|---:|---|---|---|---:| +| Office as built | 221 → 230 | 220, all | 9, 9 | 3, 0 | 12 → 0 | +| Office at permit | 222 → 232 | 222, 221 | 0 | 11, 10 | 12 → 1 | +| Core Interior | 116 | 116 | 0 | 0 | 0 | +| RE1 Architecture | 11 | 11 | 0 | 0 | 0 | + +### Room 1671995, and what the scorer's top surface is + +Round 3 of the review found room 1671995 at 323% over Revit's area as built and 350% at permit, by this scorer. It compares the plan union of the faces of each mesh that point up, within 25 degrees of vertical (`normal.z > 0.9 · length`). Room 1671995's ceiling is mostly not that flat: Revit's export has 9 flat patches of 1218.33 ft² (1145.89 at permit) and 1 hole in its top surface. `tools/re/space_overlaps_vs_ifc.py` measures each space's plan three ways, in Revit's export and in rvt-rs's (run 41723543204, the office): + +| Room 1671995 | upward faces | floor (downward faces) | every face projected | +|---|---:|---:|---:| +| Revit, as built | 1218.33 | 5177.37 | 5178.07 | +| rvt-rs, as built | 5154.12 | 5154.12 | 5154.12 | +| Revit, at permit | 1145.89 | 5176.85 | 5178.38 | +| rvt-rs, at permit | 5153.60 | 5153.60 | 5153.60 | + +The room is 5177 ft² in Revit's export, and rvt-rs is 0.45% under: its span's outer loop alone is 5176.72 ft² (run 41673400892) and the stored solid takes off the 6 holes and the notches of the columns that overlap its walls (22.6 ft²), which Revit's floor does not. Its box was 1.0% over. By the floor, over the whole office: + +| Area within 0.1% of Revit's | upward faces | floor | every face | +|---|---:|---:|---:| +| `stored_solid`, as built | 220 of 221 | 221 of 221 | 221 of 221 | +| `stored_solid`, at permit | 221 of 222 | 222 of 222 | 222 of 222 | +| `stored_solid_faces`, as built | 9 of 9 | 9 of 9 | 9 of 9 | +| `stored_solid_approximate`, as built | 0 of 4 | 0 of 4 | 0 of 4 | +| `stored_solid_approximate`, at permit | 10 of 11 | 10 of 11 | 10 of 11 | + +RE-313's 66% room at permit (2T7gRjEFvBgRaNLZHAfExM, room 2611271, STAIRCASE) is the one `stored_solid` room that the upward faces miss; by the floor it is within 0.1% of Revit's like the other 221. The scorer's top surface reads a room with a sloped or stepped ceiling as a fraction of itself. This change does not alter `space_profiles_vs_ifc` or its CI gates; the floor counts are in the new scorer, which Measure runs on every model, and the held-out models' floor counts are kept by the scrub. + +### A guard for 1671995: the four candidates + +The review asked for a discriminator that rejects 1671995 (both stages) and keeps every agreeing room, in four candidates. Measured on every room the span reads, and on the baseline of rooms the walk reads: + +1. **Wall overlap** (`space_overlaps_vs_ifc`, runs 41722753269 and 41723543204): the area of a room's top surface inside the top surfaces of the walls on its level, each shrunk by 0.02 ft. 1671995 has 1.65 ft² (0.03%) as built and 0.99 ft² (0.02%) at permit. The nine rooms that agree have 0.10% to 7.59% (7.59% is room 2297732, 10.01 ft²) and the 221 rooms the walk reads have 0.1% or more on 43, 1% on 34, 5% on 10 and up to 23.03%. The wall plans of rvt-rs's own export overlap the rooms that agree by more than they overlap 1671995. Not separating. The same test against the outlines of the other rooms on the level (what Revit's rooms cannot do) is 0.00% on every room of every source in both files. +2. **Shell closure**: a closed shell needs the floor and ceiling faces, and `face_at` reads one or more on 3 of the 13 rooms as built (1383998: 1, 1671995: 1, 2297732: 2; `probe_re316_solid_extent`, run 41707078214). In plan form, every end shared by two edges, it holds on all 24 resolved rooms or they would not close. Not testable, and not separating where it is. +3. **Volume**: the divergence-theorem volume needs the same faces. Not testable on 10 of the 13. +4. **Containment**: every outline vertex inside the record box to 0.01 ft holds on all 24 (an outline spans its box to 0.001 ft, or up to 0.02 ft short of it). Not separating. + +None works, so no guard is applied. The reason none can: 1671995's outline is not wrong. The residual risk the review named, a face of another element within 1,024 bytes after the last listed face, is not closed by candidate 1 or 2 either: it would need a closed loop of the foreign faces inside the box and clear of every wall to be read as `stored_solid_faces`, and any foreign face that crosses or overlaps a wall makes the room `stored_solid_approximate`. No solid measured has a face of another element inside 6,826 bytes of its last where the list counts the walk's faces exactly. + +### Revit's cached room areas + +RE-101 §4 found a cache of each room's computed area in its partition (`u64 · ff×8 · u32 0 · i32 · f64`) and 40 of 40 non-rectangular Core Interior rooms' outline areas in it. `probe_re316_room_closure` repeats the check (run 41710248227). It does not confirm the span areas, and does not contradict them either: + +| | Cache holds the outline's area: non-rectangular rooms the walk keeps | exact span outlines | approximate ones | +|---|---:|---:|---:| +| Core Interior | 40 of 40 | | | +| RE1 Architecture | 1 of 2 | | | +| Office as built | 71 of 103 | 0 of 10 | 0 of 3 | +| Office at permit | 20 of 116 | 2 of 11 | | +| `rac_advanced` 2019 | 0 of 21 | | | +| `rac_basic` 2019 | 0 of 7 | | | + +(Counted under the first version's labels; the rooms are the same.) For rooms 1422302, 1444351 and 1493531 the cache holds the record box's area (362.3 ft²), not the 291.5 ft² of Revit's export and of the outline. The cache is not there on every release (`rac_advanced`: none of 21 on 2019, 19 of 21 on 2024), so it is not used as evidence. + +### The Autodesk samples (no Revit export) + +`probe_re316_room_closure` on the 2019 and 2024 saves (runs 41726486862 and 41728640203): + +| Project | Rooms | Closed by RE-101 | Resolved now | Box | +|---|---:|---:|---:|---:| +| `rac_basic` | 14 | 14 | 0 | 0 | +| `rac_advanced` | 91 | 85 | 0 | 6 | +| `rme_basic`, `rme_advanced` | 76 | 26 | 1 (`stored_solid_approximate`, room 576007: 1887.2 ft² against its box's 1917.9; 2 crossing and 2 overlapping pairs) | 49 | +| Snowdon Towers (architectural, 2024) | 54 | 47 | 0 | 7 | + +### Everything else + +Measure unfiltered, `-f base=main -f nordic=true -f r2025=true -f samples=true -f holdout=true` ([41728355227](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41728355227): this change at a00e705d against `main` at 7fd339e2; every scorer including `space_profiles_vs_ifc` and the new `space_overlaps_vs_ifc`). Every scorer's output is identical on every model but five files, and the exports' hashes move on exactly the models with a room that now closes: + +- **Office as built and at permit**: the rows above. `exported.bounding_box_spaces` goes from 57 to 44 and from 56 to 45 (the 44 family instances, and room 3095831 at permit); the export counts 9 `stored_solid_faces` and 4 `stored_solid_approximate` as built, and 0 and 11 at permit. +- **`rme_basic` and `rme_advanced`, 12 files over the 6 releases 2015, 2019, 2024, 2025, 2026 and 2027**: one room each (576007, 1887.2 ft² against its box's 1917.9) takes an outline, `stored_solid_approximate`, and box spaces go 50 → 49; no Revit export of them exists. +- **Held out, counts only (BIM Speed's Revit 2021 project, Warmond)**: the one model outside the office whose scorer output changes. Area within 0.1%: 174 → 179 of the 179 paired spaces, off 5 → 0; outline and boundary within 0.01 ft 174 → 179; rooms on their record box 8 → 2 (both within 0.1%: rectangles), `stored_solid_faces` 6 (all within 0.1% and 0.01 ft, by the upward faces and by the floor), `stored_solid` 171 unchanged. Of the 8 rooms that kept their box, 5 were off in area and 6 now take an outline: the sixth was a rectangle already equal to Revit's. Vertices within 0.01 ft go from 127 to 126: that room's outline has vertices the box had not. This model was not read while the rule was found. +- **Core Interior, RE1 Architecture, the R2025 models, Vitoria's spent pair and the other held-out models**: unchanged, so no witness observation moves. `rac_advanced`, `rac_basic` and Snowdon Towers' architectural sample in every release: unchanged. +- **Refused**: no guard is applied, so no room is refused for a guard's reason. The rooms that keep their box are in §4. + +## 4. What keeps its box + +- **Office room 3095831 (permit)**, above. +- **The six `rac_advanced` rooms** (177330, 214970, 215140, 215177, 216415, 217813): round columns. With their arcs the six close (the probe's `with arcs` column). 43 of the 85 rooms RE-101 keeps in the same file have round columns it ignores, its outline 0.2% to 1.2% larger than the one with the columns taken off (room 177304: 3531.7 ft² against 3521.8). One of those two readings is wrong and no Revit export of the sample says which, so both are left as they are. +- **49 of the 50 `rme` rooms**: stepped floors and ceilings. The walls that reach the ceiling instead of the floor close for 18 of the 49 (run 41710248227; `rme_basic` and `rme_advanced` are the same), 13 of those equal the box and 5 differ from it by 1% to 11%; no Revit export of these rooms exists, and a bulkhead that hangs from the ceiling would be taken for a wall. They stay boxes. +- **Snowdon Towers' seven rooms** (#90: 828537, 828548, 830902, 830912, 2181757, 2181762, 2285535, in Autodesk's 2024 sample): their lists count more objects than the walk reads on three of them (25, 20 and 61 more: 828537, 828548 and 2181762), and no Revit export of the sample is in CI. +- **The 44 family instances written as spaces** are #1262's. + +## 5. Not claimed + +- That a `stored_solid_faces` or `stored_solid_approximate` outline equals Revit's on a release other than 2024: the evidence is one building, in two states, and one held-out project. +- That the span holds no face of another element: the rule admits at most the list's difference within 1,024 bytes of the last listed face, and neither candidate 1 nor 2 of the review closes that (§3). +- That the area of an approximate room is Revit's: 5 of the 15 office room scores are 0.45% to 0.68% under Revit's floor area, and 10 are equal. Whether Revit takes a column off the room is not in the file. +- That a cylinder's arc is Revit's on any room: `rac_advanced` has no export. +- Any volume of a resolved room (no prism marker is written). + +## 6. Reproduce + +```bash +gh workflow run measure.yml --ref feat/rooms-unclosed-solids -f ref=b714122a343e659dc2834269fd0c217569069f8d -f base=none -f samples=true -f sample_years="2019" \ + -f nordic=true -f models="nordic-office-as-built nordic-office-permit" -f probe=probe_re316_room_closure -f probe_scorer=- -f scorers=space_overlaps_vs_ifc +gh workflow run measure.yml --ref feat/rooms-unclosed-solids -f ref=7d7c098c8ede1ae8bb55d559a4bfdf270a5e70fc -f base=none -f samples=true -f sample_years="2019 2024" \ + -f nordic=true -f models="nordic-office-* core-interior re1-architecture" \ + -f probe="probe_re316_solid_extent probe_re316_room_closure probe_re316_hostile_solid" -f probe_scorer="- - -" -f scorers=space_profiles_vs_ifc +gh workflow run measure.yml --ref feat/rooms-unclosed-solids -f ref=a00e705d76ca728dbdbc3033df7c68aad94cd69e -f base=main -f nordic=true -f r2025=true -f samples=true -f holdout=true +python3 tools/re/space_overlaps_vs_ifc.py model.ifc revit-export.ifc # the floor and every-face areas, the walls and rooms a room covers +``` diff --git a/src/bin/rvt_ifc.rs b/src/bin/rvt_ifc.rs index 8ae6eed3..824869fa 100644 --- a/src/bin/rvt_ifc.rs +++ b/src/bin/rvt_ifc.rs @@ -931,6 +931,20 @@ fn export_summary(diagnostics: &ExportDiagnostics, has_sidecar: bool) -> Vec 0 { + lines.push(format!( + " {} rooms take an outline read from the faces their stored solid lists, where the exact walk did not close them (SpaceOutlineSource stored_solid_faces)", + exported.resolved_outline_spaces + )); + } + if exported.approximate_outline_spaces > 0 { + lines.push(format!( + " {} rooms take an approximate outline, a column that overlaps a wall taken off it or up to 0.02 ft short of their record box on a side: the area is within about 1% of the space's, not 0.1% (SpaceOutlineSource stored_solid_approximate)", + exported.approximate_outline_spaces + )); + } } let left_out: Vec = diagnostics .skipped diff --git a/src/ifc/export_content.rs b/src/ifc/export_content.rs index 222b2bed..7bf356c8 100644 --- a/src/ifc/export_content.rs +++ b/src/ifc/export_content.rs @@ -4174,9 +4174,30 @@ pub const RECORD_BBOX_BODY_SOURCE: &str = "partition_element_record_bbox"; pub const SPACE_OUTLINE_SOURCE_PROPERTY: &str = "SpaceOutlineSource"; /// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of a room whose outline was read from the -/// faces of its stored solid. +/// faces of its stored solid, which close into loops as they are (RE-101). pub const SPACE_OUTLINE_STORED_SOLID: &str = "stored_solid"; +/// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of a room whose outline was read from the +/// faces its stored solid lists, up to the solid's own end, where the walk of +/// RE-101 did not close them (RE-316): the wall edges close as that walk reads +/// them (one outer loop, the loops inside it nested, no edge crossing +/// another) and span the record box. Equal to Revit's area to 0.1% on every +/// such room measured, 9 of 9 on the BIM4LCA office as built. It is not +/// `stored_solid`: the solid's end is bounded by its list of objects and a gap, +/// not by the walk, so a closed loop of another element's faces inside the box +/// would be read as one of the room's. +pub const SPACE_OUTLINE_STORED_SOLID_FACES: &str = "stored_solid_faces"; + +/// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of a room whose outline was read as +/// [`SPACE_OUTLINE_STORED_SOLID_FACES`] but is the boundary of the region the +/// edges wind around, because a column overlaps a wall and its loop lies partly +/// inside the wall's, or falls up to 0.02 ft short of its record box on a side +/// (RE-316). Whether the area has the column taken off depends on Revit's +/// export, which does not always: of the 15 room scores of the BIM4LCA office +/// so read (4 as built, 11 at building permit), 5 are 0.45% to 0.68% under +/// Revit's floor area (four rooms, 1671995 in both files) and 10 are equal. +pub const SPACE_OUTLINE_STORED_SOLID_APPROXIMATE: &str = "stored_solid_approximate"; + /// [`SPACE_OUTLINE_SOURCE_PROPERTY`] of a space drawn as its record's bounding /// box (a room whose outline was not read, a Revit Space or a family instance /// written as a space): placed and sized by the box, its plan area the box's, @@ -6596,24 +6617,39 @@ fn element_record_geometry_from_decoded(decoded: &DecodedElement) -> Option + { + Some(source.as_str()) + } + _ => None, + }) + }) + .flatten(); + let marker = match source { + Some(crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE) => { + SPACE_OUTLINE_STORED_SOLID + } + Some(crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE_FACES) => { + SPACE_OUTLINE_STORED_SOLID_FACES + } + Some(crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE_APPROXIMATE) => { + SPACE_OUTLINE_STORED_SOLID_APPROXIMATE + } + _ => SPACE_OUTLINE_RECORD_BOX, + }; properties.push(Property { name: SPACE_OUTLINE_SOURCE_PROPERTY.into(), - value: PropertyValue::Text( - if from_solid { - SPACE_OUTLINE_STORED_SOLID - } else { - SPACE_OUTLINE_RECORD_BOX - } - .into(), - ), + value: PropertyValue::Text(marker.into()), }); } if let Some(number) = room_number { diff --git a/src/ifc/mod.rs b/src/ifc/mod.rs index 3e54f8f0..defea804 100644 --- a/src/ifc/mod.rs +++ b/src/ifc/mod.rs @@ -678,6 +678,23 @@ pub struct ExportedModelDiagnostics { /// names the turned box. #[serde(default)] pub bounding_box_spaces: usize, + /// 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 + /// `stored_solid_faces`, RE-316): equal to Revit's area on every such room + /// measured, but bounded by the solid's list of objects and a gap, so a + /// closed loop of another element's faces inside the room's box would be + /// read as the room's. + #[serde(default)] + pub resolved_outline_spaces: usize, + /// Of the exported `IfcSpace`s, the rooms whose resolved outline is the + /// boundary of a region, a column overlapping a wall having been taken + /// off it, or falls up to 0.02 ft short of their record box on a side + /// (`SpaceOutlineSource` is `stored_solid_approximate`, RE-316): their + /// area is approximate, up to 0.68% under Revit's floor area where + /// Revit's export does not take the column off. + #[serde(default)] + pub approximate_outline_spaces: usize, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -4217,6 +4234,8 @@ 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 resolved_outline_spaces = 0usize; + let mut approximate_outline_spaces = 0usize; for (index, entity) in model.entities.iter().enumerate() { if let entities::IfcEntity::BuildingElement { ifc_type, @@ -4264,6 +4283,17 @@ fn exported_model_diagnostics(model: &IfcModel) -> ExportedModelDiagnostics { { bounding_box_spaces += 1; } + if ifc_type == "IFCSPACE" { + match space_outline.map(String::as_str) { + Some(export_content::SPACE_OUTLINE_STORED_SOLID_FACES) => { + resolved_outline_spaces += 1; + } + Some(export_content::SPACE_OUTLINE_STORED_SOLID_APPROXIMATE) => { + approximate_outline_spaces += 1; + } + _ => {} + } + } building_elements += 1; *by_ifc_type.entry(ifc_type.clone()).or_insert(0) += 1; if storey_index.is_some() { @@ -4313,6 +4343,8 @@ fn exported_model_diagnostics(model: &IfcModel) -> ExportedModelDiagnostics { body_sources, bounding_box_bodies, bounding_box_spaces, + resolved_outline_spaces, + approximate_outline_spaces, } } diff --git a/src/ifc/schedule_csv.rs b/src/ifc/schedule_csv.rs index 82969e53..e34ee140 100644 --- a/src/ifc/schedule_csv.rs +++ b/src/ifc/schedule_csv.rs @@ -303,7 +303,9 @@ pub fn rooms_csv(model: &IfcModel, options: &CsvOptions) -> String { number(storey.map(|s| unit.convert(s.elevation_feet))), number( (property("ProfileSource") - == crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE) + == crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE + || property("ProfileSource") + == crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE_FACES) .then(|| extrusion.as_ref().and_then(outline_area_square_feet)) .flatten() .map(|area| unit.convert_area(area)), diff --git a/src/research/element_record_plan_profiles.rs b/src/research/element_record_plan_profiles.rs index 85a3e436..55618e45 100644 --- a/src/research/element_record_plan_profiles.rs +++ b/src/research/element_record_plan_profiles.rs @@ -289,7 +289,7 @@ pub fn plan_profile_from_fields(fields: &[(String, InstanceField)]) -> Option { source_ok = text == PLAN_PROFILE_SOURCE - || text == crate::partition_room_boundaries::ROOM_OUTLINE_SOURCE + || crate::partition_room_boundaries::is_room_outline_source(text) || text == crate::partition_schema_mvp::LANDING_OUTLINE_SOURCE; } (PLAN_PROFILE_OUTER_FIELD, field) => outer = points_from_field(field), diff --git a/src/research/partition_room_boundaries.rs b/src/research/partition_room_boundaries.rs index 0908615e..32cd9df1 100644 --- a/src/research/partition_room_boundaries.rs +++ b/src/research/partition_room_boundaries.rs @@ -31,6 +31,32 @@ //! vertical face that stops short of the floor is a step in the room's //! ceiling, inside the outline. //! +//! A record the walk does not read can sit between two faces and be longer +//! than [`FACE_GAP`] (626 bytes in office room 1422302), and a face's box is +//! the box of its plane, not its outline: where a column stands in the room +//! and overlaps a wall, the wall's face runs across the column's footprint. +//! Only where the walk closes none of a room's solids is the solid read again +//! (RE-316): its faces are followed with a gap of up to [`SPAN_FACE_GAP`] +//! bytes, and no more than its list of objects counts ([`solid_span`], +//! [`solid_object_count`]), so the faces of the element that follows it are +//! not read. Where the span's wall edges close as the walk reads them (one +//! outer loop, the loops inside it nested, no edge crossing another; their +//! ends joined within [`ROOM_STEP_TOLERANCE_FEET`], where the walk joins them +//! within 1e-4 ft) that is +//! the outline ([`ROOM_OUTLINE_SOURCE_FACES`]). Where a column overlaps a wall +//! and its loop lies partly inside the wall's, each plan edge is directed so +//! the room is on its left and the outline is the boundary of the region those +//! edges wind around ([`region_boundary`]), the column taken off +//! ([`ROOM_OUTLINE_SOURCE_APPROXIMATE`]: whether Revit's export takes it off is +//! not settled by the file). A room read either way says so in its profile +//! source, never +//! as the walk's. A face whose record is 44 bytes longer than a plane's is a +//! cylinder's (a round column): its axis, a unit vector at right angles to +//! the face's u and v, at +158, and its radius at +182 ([`RoomCylinder`]). Its +//! arc closes the loops of the six `rac_advanced` rooms RE-101 left open, but +//! whether Revit takes a column off a room's area is unmeasured (it does not +//! on four office rooms), so a solid with one is left unresolved. +//! //! Core Interior keeps a room's record and solid in more than one partition, //! and the copies can disagree: room 20832's outline spans 10.5 ft in one and //! 10.4167 ft in the other. The copy kept is the one in the partition of the @@ -45,9 +71,32 @@ use crate::{Result, RevitFile}; use std::collections::BTreeMap; /// Value of [`crate::element_record_plan_profiles::PLAN_PROFILE_SOURCE_FIELD`] -/// on a room outline read here. +/// on a room outline read here from the faces the walk reads, closing as they +/// are (RE-101). pub const ROOM_OUTLINE_SOURCE: &str = "partition_room_solid_faces"; +/// Value of the same field on a room outline read from the faces its solid +/// lists, up to the solid's own end, where the walk did not close them and +/// the span's wall edges close as the walk reads them (RE-316). Not the +/// walk's: a closed loop of another element's faces inside the box could still +/// be read into it, so an importer tells the two apart. +pub const ROOM_OUTLINE_SOURCE_FACES: &str = "partition_room_solid_faces_resolved"; + +/// Value of the same field on a room outline that is the boundary of the region +/// the span's edges wind around, a column overlapping a wall taken off it, or +/// falls up to [`ROOM_SLIVER_FEET`] short of the record box on a side (RE-316): +/// approximate, up to 0.68% under Revit's floor area where its export does not +/// take the column off. +pub const ROOM_OUTLINE_SOURCE_APPROXIMATE: &str = "partition_room_solid_faces_approximate"; + +/// Whether `source` is a value of the profile source field that a room's +/// outline from its stored solid carries. +pub fn is_room_outline_source(source: &str) -> bool { + source == ROOM_OUTLINE_SOURCE + || source == ROOM_OUTLINE_SOURCE_FACES + || source == ROOM_OUTLINE_SOURCE_APPROXIMATE +} + /// Releases this layout is measured on. On Revit 2026 and 2027 it is the /// 2025 layout: every room of Autodesk's `rac_basic`, `rac_advanced`, /// `rme_basic` and `rme_advanced` sample projects (257) gives the same @@ -74,6 +123,18 @@ pub const ROOM_SOLID_WINDOW: usize = 0x1_0000; /// feet. pub const BOX_TOLERANCE_FEET: f64 = 1e-3; +/// The most bytes from one face's tag to the next face's within one solid +/// when its list of objects bounds the span (RE-316). +pub const SPAN_FACE_GAP: usize = 0x400; + +/// The most faces a solid may have to be read at all (RE-316): the largest +/// solid measured lists 136 objects. A solid with more is left to its box, so +/// the work of snapping and resolving its edges is bounded. +pub const SOLID_FACES_MAX: usize = 512; + +/// The most faces a solid's span may hold before it is not resolved (RE-316). +pub const RESOLVE_FACES_MAX: usize = SOLID_FACES_MAX; + /// How far a vertical face's span may fall short of a room's box, and a /// horizontal face lie from its floor or ceiling, and still be it, feet /// (RE-296). @@ -85,6 +146,37 @@ pub const PRISM_TOLERANCE_FEET: f64 = 1e-3; /// 355008. pub const ROOM_VERTEX_TOLERANCE_FEET: f64 = 1e-4; +/// How far apart two wall faces' ends may lie and still meet where the room's +/// faces are resolved to a boundary (RE-316). Walls on lines 0.0007 to 0.0015 +/// ft apart leave a step the room's faces do not cross on six rooms of the +/// BIM4LCA office; Revit's own short-curve tolerance is 1/32 inch, 0.0026 ft. +pub const ROOM_STEP_TOLERANCE_FEET: f64 = 0.0025; + +/// How far a chord of a column's arc may stray from the arc, feet (as +/// [`crate::element_record_plan_profiles::SKETCH_ARC_TOLERANCE_FEET`]). +const ARC_CHORD_TOLERANCE_FEET: f64 = 0.0005; + +/// How far a resolved outline may fall short of the room's record box on a +/// side and still be a room's outline, labelled approximate, feet (RE-316): +/// the back of a column that overlaps a wall stands 0.0164 ft behind the +/// wall's face on three rooms of the BIM4LCA office, and the box holds it. +pub const ROOM_SLIVER_FEET: f64 = 0.02; + +/// The most plan segments [`region_boundary`] resolves. +pub const BOUNDARY_SEGMENTS_MAX: usize = RESOLVE_FACES_MAX; + +/// The most pieces the segments of [`region_boundary`] may be cut into: a +/// room's own faces cross a few times, and a crafted grid of them crosses +/// quadratically. +pub const BOUNDARY_PIECES_MAX: usize = 4096; + +/// How far apart two points of a resolved boundary may lie and be one, +/// feet. +const BOUNDARY_POINT_EPS: f64 = 1e-6; + +/// How far to either side of a boundary piece its winding is read, feet. +const BOUNDARY_SIDE_EPS: f64 = 1e-5; + const FACE_LEN: usize = 158; const UNIT_EPS: f64 = 1e-6; const AXIS_EPS: f64 = 1e-9; @@ -100,6 +192,21 @@ pub struct RoomFace { pub u: [f64; 3], /// Unit v direction. pub v: [f64; 3], + /// The axis and radius where the face is a cylinder's: its record is 44 + /// bytes longer than a plane's, with the axis (a unit vector at right + /// angles to u and v) and the radius after the 158 bytes a plane's + /// has, and its uv box is an angle about the axis and a distance along it + /// (RE-316). + pub cylinder: Option, +} + +/// The axis and radius of a cylindrical face of a room's solid. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RoomCylinder { + /// Unit axis. + pub axis: [f64; 3], + /// Radius, feet. + pub radius: f64, } impl RoomFace { @@ -199,9 +306,23 @@ pub fn face_at(buf: &[u8], at: usize) -> Option { origin, u, v, + cylinder: cylinder_at(buf, at, u, v), }) } +/// The cylinder the face record at `at` describes, where the 24 bytes after +/// its 158 are a unit axis at right angles to `u` and `v` and the 8 after +/// them a positive radius. A plane's record has `ff` bytes there. +fn cylinder_at(buf: &[u8], at: usize, u: [f64; 3], v: [f64; 3]) -> Option { + let axis: [f64; 3] = read_f64s(buf, at.checked_add(FACE_LEN)?)?; + let radius = le_bytes::f64_at(buf, at.checked_add(FACE_LEN + 24)?)?; + let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + let unit = (dot(axis, axis) - 1.0).abs() < UNIT_EPS; + let square = dot(axis, u).abs() < UNIT_EPS && dot(axis, v).abs() < UNIT_EPS; + (unit && square && radius.is_finite() && radius > 1e-4 && radius < 1e4) + .then_some(RoomCylinder { axis, radius }) +} + /// Where the solid whose header starts at `header` begins: past its /// [`ROOM_SOLID_TAG`], directly after the ElementId or, on Revit 2023, /// after `ff ff ff ff`. @@ -214,29 +335,28 @@ fn solid_start(buf: &[u8], header: usize) -> Option { .then_some(after_id + 8) } -/// The faces of the solid whose header starts at `header`: the first face -/// within [`ROOM_SOLID_WINDOW`], and each face after it within -/// [`FACE_GAP`] of the one before. -pub fn solid_faces(buf: &[u8], header: usize) -> Vec { - let Some(start) = solid_start(buf, header) else { - return Vec::new(); - }; - let end = buf.len().min(start + ROOM_SOLID_WINDOW); - let Some(window) = buf.get(start..end) else { - return Vec::new(); - }; - let Some(first) = memchr::memmem::find_iter(window, &FACE_TAG) +/// The first face of the solid that starts at `start`, within +/// [`ROOM_SOLID_WINDOW`]. +fn first_face_at(buf: &[u8], start: usize) -> Option { + let end = buf.len().min(start.checked_add(ROOM_SOLID_WINDOW)?); + let window = buf.get(start..end)?; + memchr::memmem::find_iter(window, &FACE_TAG) .map(|offset| start + offset) .find(|&at| face_at(buf, at).is_some()) - else { - return Vec::new(); - }; +} + +/// The faces from the one at `first`, each the first face within `max_gap` +/// bytes of its tag after the one before, up to `limit` of them. +fn follow_faces(buf: &[u8], first: usize, max_gap: usize, limit: usize) -> Vec { let mut faces = Vec::new(); let mut at = first; while let Some(face) = face_at(buf, at) { faces.push(face); + if faces.len() >= limit { + break; + } let from = at + FACE_TAG.len(); - let Some(window) = buf.get(from..buf.len().min(at + FACE_GAP)) else { + let Some(window) = buf.get(from..buf.len().min(at + max_gap)) else { break; }; let Some(next) = memchr::memmem::find_iter(window, &FACE_TAG) @@ -250,33 +370,377 @@ pub fn solid_faces(buf: &[u8], header: usize) -> Vec { faces } -/// The room's outline from the solid whose header starts at `header`, where -/// its wall faces (vertical, reaching the floor of `bbox`, a record box -/// `[min x, min y, min z, max x, max y, max z]`) close into loops whose plan -/// extent is `bbox`'s within -/// [`BOX_TOLERANCE_FEET`]. A face's origin is its plane's, not a point on -/// the face, so only the plan edges are checked. -pub fn room_outline_at(buf: &[u8], header: usize, bbox: [f64; 6]) -> Option { - let faces = solid_faces(buf, header); - let mut lines = Vec::new(); - for face in &faces { - if let Some((edge, bottom)) = face.plan_edge() { - if (bottom - bbox[2]).abs() <= BOX_TOLERANCE_FEET { - lines.push(edge); - } +/// The faces of the solid whose header starts at `header`: the first face +/// within [`ROOM_SOLID_WINDOW`], and each face after it within +/// [`FACE_GAP`] of the one before. None if there are more than +/// [`SOLID_FACES_MAX`]. +pub fn solid_faces(buf: &[u8], header: usize) -> Vec { + let Some(start) = solid_start(buf, header) else { + return Vec::new(); + }; + let Some(first) = first_face_at(buf, start) else { + return Vec::new(); + }; + let faces = follow_faces(buf, first, FACE_GAP, SOLID_FACES_MAX + 1); + if faces.len() > SOLID_FACES_MAX { + return Vec::new(); + } + faces +} + +/// The bytes that open the list of a solid's objects, after its two boxes. +const SOLID_LIST_TAG: [u8; 4] = [0x04, 0xc0, 0x08, 0x00]; + +/// How far past its start a solid's list of objects is looked for. +const SOLID_LIST_WINDOW: usize = 0x200; + +/// The number of objects the solid that starts at `start` lists (RE-316): the +/// list is `04 c0 08 00 · u32 count · u32 4 · count × (u16 class · u32 +/// token)` and counts every face, including those whose record +/// [`face_at`] does not read (a solid's floor and ceiling, usually). None +/// unless the list is there with a count from 1 to [`RESOLVE_FACES_MAX`]. +pub fn solid_object_count(buf: &[u8], start: usize) -> Option { + let end = buf.len().min(start.checked_add(SOLID_LIST_WINDOW)?); + let window = buf.get(start..end)?; + let at = start + memchr::memmem::find(window, &SOLID_LIST_TAG)?; + let count = le_bytes::u32_at(buf, at + 4)? as usize; + let lead = le_bytes::u32_at(buf, at + 8)?; + (lead == 4 && (1..=RESOLVE_FACES_MAX).contains(&count)).then_some(count) +} + +impl RoomFace { + /// The heights, low and high, a cylindrical face with a vertical axis + /// spans, feet. + fn vertical_cylinder_heights(&self) -> Option<(f64, f64)> { + let cylinder = self.cylinder?; + let horizontal = |d: [f64; 3]| d[2].abs() < AXIS_EPS; + if (cylinder.axis[2].abs() - 1.0).abs() >= AXIS_EPS + || !horizontal(self.u) + || !horizontal(self.v) + { + return None; + } + let ends = [self.uv_box[1], self.uv_box[3]] + .map(|distance| self.origin[2] + cylinder.axis[2] * distance); + Some((ends[0].min(ends[1]), ends[0].max(ends[1]))) + } + + /// The chords of a vertical cylinder's arc as plan segments, run + /// clockwise around its axis, which is how the walls of a column run + /// round the room (the room is on their left); within + /// [`ARC_CHORD_TOLERANCE_FEET`] of the arc. + fn arc_segments(&self) -> Option> { + let cylinder = self.cylinder?; + self.vertical_cylinder_heights()?; + let (start, end) = (self.uv_box[0], self.uv_box[2]); + let sweep = end - start; + if !(sweep > 0.0 && sweep <= std::f64::consts::TAU + 1e-6) { + return None; } + let radius = cylinder.radius; + let largest_chord_angle = 2.0 + * (1.0 - ARC_CHORD_TOLERANCE_FEET / radius) + .clamp(-1.0, 1.0) + .acos(); + let chords = ((sweep / largest_chord_angle.max(1e-3)).ceil() as usize).clamp(1, 1024); + let point = |angle: f64| { + let (sine, cosine) = angle.sin_cos(); + [ + self.origin[0] + radius * (cosine * self.u[0] + sine * self.v[0]), + self.origin[1] + radius * (cosine * self.u[1] + sine * self.v[1]), + ] + }; + let counter_clockwise = self.u[0] * self.v[1] - self.u[1] * self.v[0] > 0.0; + let angles: Vec = (0..=chords) + .map(|index| start + sweep * index as f64 / chords as f64) + .collect(); + let ordered: Vec = if counter_clockwise { + angles.into_iter().rev().collect() + } else { + angles + }; + Some( + ordered + .windows(2) + .map(|pair| { + let (from, to) = (point(pair[0]), point(pair[1])); + [from[0], from[1], to[0], to[1]] + }) + .collect(), + ) } - snap_ends(&mut lines); - let profile = plan_profile_from_lines(&lines)?; +} + +/// Which end of the room its wall faces must reach to be a wall of its +/// outline. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutlineAnchor { + /// The room's floor, the bottom of its record box. + Floor, + /// The room's ceiling, the top of its record box. + Ceiling, +} + +fn reaches_anchor(face: &RoomFace, bbox: [f64; 6], anchor: OutlineAnchor) -> Option<[f64; 4]> { + let (edge, bottom) = face.plan_edge()?; + let reached = match anchor { + OutlineAnchor::Floor => (bottom - bbox[2]).abs() <= BOX_TOLERANCE_FEET, + OutlineAnchor::Ceiling => match face.kind() { + FaceKind::Vertical { high, .. } => (high - bbox[5]).abs() <= BOX_TOLERANCE_FEET, + _ => false, + }, + }; + reached.then_some(edge) +} + +/// Whether a vertical `face` reaches outside the room's record box, which +/// holds the whole solid: a face of another element, read because the span +/// ran on past the solid's end. +fn strays_from_box(face: &RoomFace, bbox: [f64; 6]) -> bool { + let tolerance = BOX_TOLERANCE_FEET; + let outside = |edge: [f64; 4]| { + edge[0].min(edge[2]) < bbox[0] - tolerance + || edge[0].max(edge[2]) > bbox[3] + tolerance + || edge[1].min(edge[3]) < bbox[1] - tolerance + || edge[1].max(edge[3]) > bbox[4] + tolerance + }; + if face.cylinder.is_some() { + let (Some((low, high)), Some(arc)) = + (face.vertical_cylinder_heights(), face.arc_segments()) + else { + return true; + }; + return low < bbox[2] - tolerance + || high > bbox[5] + tolerance + || arc.iter().any(|segment| outside(*segment)); + } + let FaceKind::Vertical { low, high } = face.kind() else { + return false; + }; + if low < bbox[2] - tolerance || high > bbox[5] + tolerance { + return true; + } + face.plan_edge().is_some_and(|(edge, _)| outside(edge)) +} + +/// `profile` where its plan extent equals `bbox`'s on each side within +/// [`BOX_TOLERANCE_FEET`], or, where `slack` is set, falls short of it by no +/// more than `slack` on any side. +fn profile_on_box(profile: PlanProfile, bbox: [f64; 6], slack: f64) -> Option { let extent = profile.plan_extent_feet()?; + let inward = [1.0, 1.0, -1.0, -1.0]; let wanted = [bbox[0], bbox[1], bbox[3], bbox[4]]; extent .iter() .zip(wanted) - .all(|(got, want)| (got - want).abs() <= BOX_TOLERANCE_FEET) + .zip(inward) + .all(|((got, want), sign)| { + let short = (got - want) * sign; + short >= -BOX_TOLERANCE_FEET && short <= slack + BOX_TOLERANCE_FEET + }) .then_some(profile) } +/// How the faces of a span are read into an outline (RE-316). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SpanRead { + /// Read the arcs of round columns too. Off, a solid with a cylinder is + /// not resolved. + pub arcs: bool, + /// How far the outline may fall short of the record box on a side. + pub slack: f64, +} + +/// The plan edges of the wall faces among `faces` that reach `anchor`, and, +/// where `span` reads arcs, of the round columns that do. +fn wall_edges( + faces: &[RoomFace], + bbox: [f64; 6], + anchor: OutlineAnchor, + span: Option, +) -> Option> { + let mut lines = Vec::new(); + for face in faces { + if span.is_none() || face.cylinder.is_none() { + if let Some(edge) = reaches_anchor(face, bbox, anchor) { + lines.push(edge); + } + } + if span.is_some_and(|read| read.arcs) { + if let Some((low, high)) = face.vertical_cylinder_heights() { + let reached = match anchor { + OutlineAnchor::Floor => (low - bbox[2]).abs() <= BOX_TOLERANCE_FEET, + OutlineAnchor::Ceiling => (high - bbox[5]).abs() <= BOX_TOLERANCE_FEET, + }; + if reached { + lines.extend(face.arc_segments()?); + } + } + } + } + Some(lines) +} + +/// Whether any two of the plan `segments` cross, or overlap along a line, +/// anywhere but at an end both have. +fn crosses(segments: &[[f64; 4]]) -> bool { + const END: f64 = 1e-9; + for (index, first) in segments.iter().enumerate() { + for second in &segments[index + 1..] { + let (on_first, on_second) = cut_parameters(*first, *second); + if on_first + .iter() + .chain(&on_second) + .any(|t| *t > END && *t < 1.0 - END) + { + return true; + } + } + } + false +} + +/// The outline the faces of a span make (RE-316): with the plan edges of its +/// walls that reach `anchor`, ends within [`ROOM_STEP_TOLERANCE_FEET`] joined, +/// and a flag, set where those edges close as RE-101's walk reads them (every +/// end shared by two edges, loops that nest, one outer loop, no edge crossing +/// another) and otherwise unset, the outline then being the boundary of the +/// region the edges wind around ([`region_boundary`]), which subtracts a +/// column that overlaps a wall. `None` unless the outline spans +/// `bbox` as `read` allows; a face outside `bbox`, a cylinder not read as an +/// arc and more than [`RESOLVE_FACES_MAX`] faces rule the span out. +fn span_outline( + faces: &[RoomFace], + bbox: [f64; 6], + anchor: OutlineAnchor, + read: SpanRead, +) -> Option<(PlanProfile, bool)> { + if faces.len() > RESOLVE_FACES_MAX + || faces.iter().any(|face| strays_from_box(face, bbox)) + || (!read.arcs && faces.iter().any(|face| face.cylinder.is_some())) + { + return None; + } + let mut lines = wall_edges(faces, bbox, anchor, Some(read))?; + snap_ends_within(&mut lines, ROOM_STEP_TOLERANCE_FEET); + if let Some(profile) = plan_profile_from_lines(&lines) { + if profile.pieces.is_empty() && !crosses(&lines) { + return profile_on_box(profile, bbox, read.slack).map(|profile| (profile, true)); + } + } + let boundary = region_boundary(&lines).ok()?; + profile_on_box(plan_profile_from_lines(&boundary)?, bbox, read.slack) + .map(|profile| (profile, false)) +} + +/// The outline the wall faces among `faces` make, kept where it spans +/// `bbox`: for the walk of RE-101 (`span` unset) the faces' plan edges must +/// close into loops as they are; for a span they are read by [`span_outline`]. +fn outline_from_faces( + faces: &[RoomFace], + bbox: [f64; 6], + anchor: OutlineAnchor, + span: Option, +) -> Option { + match span { + None => { + let mut lines = wall_edges(faces, bbox, anchor, None)?; + snap_ends(&mut lines); + profile_on_box(plan_profile_from_lines(&lines)?, bbox, 0.0) + } + Some(read) => span_outline(faces, bbox, anchor, read).map(|(profile, _)| profile), + } +} + +/// The room's outline from the solid whose header starts at `header`, where +/// its wall faces (vertical, reaching the floor of `bbox`, a record box +/// `[min x, min y, min z, max x, max y, max z]`) close into loops whose plan +/// extent is `bbox`'s within [`BOX_TOLERANCE_FEET`]. A face's origin is its +/// plane's, not a point on the face, so only the plan edges are checked. +pub fn room_outline_at(buf: &[u8], header: usize, bbox: [f64; 6]) -> Option { + outline_from_faces(&solid_faces(buf, header), bbox, OutlineAnchor::Floor, None) +} + +/// The outline the faces up to the solid's end make, resolved to a boundary, +/// for walls that reach `anchor`, read as `read` says (RE-316). A probe's +/// reading: [`resolved_outline_of_solids`] uses only [`OutlineAnchor::Floor`]. +pub fn span_outline_at( + buf: &[u8], + header: usize, + bbox: [f64; 6], + anchor: OutlineAnchor, + read: SpanRead, +) -> Option { + outline_from_faces(&solid_span(buf, header), bbox, anchor, Some(read)) +} + +/// How a resolved outline was read, and so what to trust of its area +/// (RE-316). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResolvedKind { + /// The wall edges of the span close as the walk of RE-101 reads them: + /// one outer loop, the loops inside it nested, no edge crossing another, + /// and the outline spanning the record box to [`BOX_TOLERANCE_FEET`] on + /// every side. A face of another element would have to close a loop of + /// its own inside the box, within the gap and the count that bound the + /// span, to be read. + Faces, + /// The outline is the boundary of the region the edges wind around, + /// because a column overlaps a wall and its loop lies partly inside the + /// wall's, or an end of one wall edge lies on another, or it falls up to [`ROOM_SLIVER_FEET`] short of the record box + /// on a side. Revit's export does not always take such a column off the + /// room's area, so the area is approximate: 0.45% to 0.7% under Revit's + /// floor area on the four office rooms where it does not. + Approximate, +} + +/// The outline of a room whose solids at `headers` none gives an outline the +/// walk reads ([`outline_of_solids`] is `None`), from the faces of each +/// solid's span (RE-316): [`ResolvedKind::Faces`] where they close as the walk +/// reads them, else [`ResolvedKind::Approximate`], also tried with +/// [`ROOM_SLIVER_FEET`] of slack. Where more than one solid gives one, every +/// one must give the same. +pub fn resolved_outline_of_solids( + buf: &[u8], + headers: &[usize], + bbox: [f64; 6], +) -> Option<(PlanProfile, ResolvedKind)> { + let mut found: Option<(PlanProfile, ResolvedKind)> = None; + for &at in headers { + let faces = solid_span(buf, at); + let exact = SpanRead { + arcs: false, + slack: 0.0, + }; + let slack = SpanRead { + arcs: false, + slack: ROOM_SLIVER_FEET, + }; + let Some(this) = span_outline(&faces, bbox, OutlineAnchor::Floor, exact) + .map(|(profile, simple)| { + let kind = if simple { + ResolvedKind::Faces + } else { + ResolvedKind::Approximate + }; + (profile, kind) + }) + .or_else(|| { + span_outline(&faces, bbox, OutlineAnchor::Floor, slack) + .map(|(profile, _)| (profile, ResolvedKind::Approximate)) + }) + else { + continue; + }; + match &found { + None => found = Some(this), + Some(held) if *held == this => {} + Some(_) => return None, + } + } + found +} + /// Whether `faces`, the faces of a room's solid, make a prism of the room's /// plan outline between the floor and ceiling of `bbox` (RE-296): there is a /// vertical face, every vertical face runs from the floor to the ceiling, and @@ -357,17 +821,232 @@ pub fn solid_headers_by_id(buf: &[u8]) -> BTreeMap> { out } +/// The faces of the solid whose header starts at `header` that the walk +/// stops short of (RE-316): from its first face, each next face within +/// [`SPAN_FACE_GAP`] bytes of the one before, and no more than the solid's +/// list of objects counts ([`solid_object_count`]). A record longer than +/// [`FACE_GAP`] can sit between a solid's own faces (592 to 732 bytes on the +/// BIM4LCA office and Autodesk's samples). On the 47 solids measured whose list +/// counts exactly the faces the walk reads, the next face after the walk, which +/// is another element's, is 6826 bytes or more on. Empty without the list or a +/// first face. +pub fn solid_span(buf: &[u8], header: usize) -> Vec { + let Some(start) = solid_start(buf, header) else { + return Vec::new(); + }; + let (Some(count), Some(first)) = (solid_object_count(buf, start), first_face_at(buf, start)) + else { + return Vec::new(); + }; + follow_faces(buf, first, SPAN_FACE_GAP, count) +} + +/// Where each of two segments is cut by the other, as parameters from 0 at +/// its start to 1 at its end: at a crossing, and, where they overlap along +/// one line, at the other's ends. +fn cut_parameters(a: [f64; 4], b: [f64; 4]) -> (Vec, Vec) { + let (mut on_a, mut on_b) = (Vec::new(), Vec::new()); + let (ax, ay) = (a[2] - a[0], a[3] - a[1]); + let (bx, by) = (b[2] - b[0], b[3] - b[1]); + let (alen, blen) = (ax.hypot(ay), bx.hypot(by)); + if alen <= BOUNDARY_POINT_EPS || blen <= BOUNDARY_POINT_EPS { + return (on_a, on_b); + } + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let cross = ax * by - ay * bx; + if cross.abs() > 1e-9 * alen * blen { + let t = (dx * by - dy * bx) / cross; + let u = (dx * ay - dy * ax) / cross; + let (t_slack, u_slack) = (BOUNDARY_POINT_EPS / alen, BOUNDARY_POINT_EPS / blen); + if t >= -t_slack && t <= 1.0 + t_slack && u >= -u_slack && u <= 1.0 + u_slack { + on_a.push(t); + on_b.push(u); + } + } else if ((dx * ay - dy * ax) / alen).abs() <= 10.0 * BOUNDARY_POINT_EPS { + let along_a = |x: f64, y: f64| ((x - a[0]) * ax + (y - a[1]) * ay) / (alen * alen); + let along_b = |x: f64, y: f64| ((x - b[0]) * bx + (y - b[1]) * by) / (blen * blen); + on_a.push(along_a(b[0], b[1])); + on_a.push(along_a(b[2], b[3])); + on_b.push(along_b(a[0], a[1])); + on_b.push(along_b(a[2], a[3])); + } + (on_a, on_b) +} + +/// How many times the oriented `segments` wind around `p`: each upward +/// crossing of the ray to its right with `p` on the segment's left adds one, +/// each downward crossing with `p` on its right takes one off. +fn winding(segments: &[[f64; 4]], p: (f64, f64)) -> i32 { + let mut total = 0; + for &[x0, y0, x1, y1] in segments { + let side = (x1 - x0) * (p.1 - y0) - (p.0 - x0) * (y1 - y0); + if y0 <= p.1 { + if y1 > p.1 && side > 0.0 { + total += 1; + } + } else if y1 <= p.1 && side < 0.0 { + total -= 1; + } + } + total +} + +fn intern_point(points: &mut Vec<(f64, f64)>, p: (f64, f64)) -> usize { + let found = points.iter().position(|q| { + (q.0 - p.0).abs() <= BOUNDARY_POINT_EPS && (q.1 - p.1).abs() <= BOUNDARY_POINT_EPS + }); + match found { + Some(index) => index, + None => { + points.push(p); + points.len() - 1 + } + } +} + +/// Why [`region_boundary`] gave no boundary. +#[derive(Debug, Clone, PartialEq)] +pub enum BoundaryFailure { + /// There are fewer than three segments, more than + /// [`BOUNDARY_SEGMENTS_MAX`], or one has an end that is not finite. + Segments(usize), + /// The segments cross so often that they would be cut into more than + /// [`BOUNDARY_PIECES_MAX`] pieces. + Pieces, + /// Fewer than three pieces bound the region. + NoRegion, + /// The points where the pieces that bound the region do not run one in + /// and one out. + Unlinked(Vec<(f64, f64)>), +} + +/// The boundary of the region that the oriented plan `segments` wind around +/// at least once, as undirected segments that close into loops (RE-316). A +/// segment's room is on its left, so a wall of the room runs counter-clockwise +/// around it and a column's walls clockwise around the column, and a column +/// that overlaps a wall takes its footprint off the room where they overlap +/// and adds nothing outside it. Every segment is cut where another crosses it +/// or touches it, a piece is kept where the region lies on one side of it +/// only, and `None` is returned unless the kept pieces leave every point with +/// one piece in and one out. +#[allow(clippy::needless_range_loop)] +pub fn region_boundary(segments: &[[f64; 4]]) -> Result, BoundaryFailure> { + let count = segments.len(); + if !(3..=BOUNDARY_SEGMENTS_MAX).contains(&count) + || segments.iter().flatten().any(|v| !v.is_finite()) + { + return Err(BoundaryFailure::Segments(count)); + } + let mut cuts: Vec> = vec![Vec::new(); count]; + let mut cut_total = 0usize; + for first in 0..count { + for second in first + 1..count { + let (on_first, on_second) = cut_parameters(segments[first], segments[second]); + cut_total += on_first.len() + on_second.len(); + if cut_total + count > BOUNDARY_PIECES_MAX { + return Err(BoundaryFailure::Pieces); + } + cuts[first].extend(on_first); + cuts[second].extend(on_second); + } + } + let mut points: Vec<(f64, f64)> = Vec::new(); + let mut pieces: Vec<(usize, usize)> = Vec::new(); + for (index, segment) in segments.iter().enumerate() { + let length = (segment[2] - segment[0]).hypot(segment[3] - segment[1]); + if length <= BOUNDARY_POINT_EPS { + continue; + } + let mut params = vec![0.0, 1.0]; + params.extend(cuts[index].iter().copied().filter(|t| { + t * length > BOUNDARY_POINT_EPS && (1.0 - t) * length > BOUNDARY_POINT_EPS + })); + params.sort_by(f64::total_cmp); + params.dedup_by(|a, b| (*a - *b).abs() * length <= BOUNDARY_POINT_EPS); + let at = |t: f64| { + ( + segment[0] + t * (segment[2] - segment[0]), + segment[1] + t * (segment[3] - segment[1]), + ) + }; + for pair in params.windows(2) { + let from = intern_point(&mut points, at(pair[0])); + let to = intern_point(&mut points, at(pair[1])); + if from != to { + pieces.push((from, to)); + } + } + } + let mut kept: Vec<(usize, usize)> = Vec::new(); + for &(from, to) in &pieces { + let (a, b) = (points[from], points[to]); + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let length = dx.hypot(dy); + let (nx, ny) = (-dy / length, dx / length); + let middle = ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0); + let left = winding( + segments, + ( + middle.0 + nx * BOUNDARY_SIDE_EPS, + middle.1 + ny * BOUNDARY_SIDE_EPS, + ), + ) >= 1; + let right = winding( + segments, + ( + middle.0 - nx * BOUNDARY_SIDE_EPS, + middle.1 - ny * BOUNDARY_SIDE_EPS, + ), + ) >= 1; + match (left, right) { + (true, false) => kept.push((from, to)), + (false, true) => kept.push((to, from)), + _ => {} + } + } + kept.sort_unstable(); + kept.dedup(); + if kept.len() < 3 { + return Err(BoundaryFailure::NoRegion); + } + let mut leaving = vec![0usize; points.len()]; + let mut entering = vec![0usize; points.len()]; + for &(from, to) in &kept { + leaving[from] += 1; + entering[to] += 1; + } + let unlinked: Vec<(f64, f64)> = leaving + .iter() + .zip(&entering) + .zip(&points) + .filter(|((out, into), _)| out != into || **out > 1) + .map(|(_, point)| *point) + .collect(); + if !unlinked.is_empty() { + return Err(BoundaryFailure::Unlinked(unlinked)); + } + Ok(kept + .iter() + .map(|&(from, to)| [points[from].0, points[from].1, points[to].0, points[to].1]) + .collect()) +} + /// Move every end within [`ROOM_VERTEX_TOLERANCE_FEET`] of an earlier one /// onto it. fn snap_ends(lines: &mut [[f64; 4]]) { + snap_ends_within(lines, ROOM_VERTEX_TOLERANCE_FEET); +} + +/// Move every end within `tolerance` of an earlier one onto it. +fn snap_ends_within(lines: &mut [[f64; 4]], tolerance: f64) { let mut held: Vec<(f64, f64)> = Vec::new(); for line in lines.iter_mut() { for end in [0, 2] { let point = (line[end], line[end + 1]); - let near = held.iter().copied().find(|q| { - (q.0 - point.0).abs() <= ROOM_VERTEX_TOLERANCE_FEET - && (q.1 - point.1).abs() <= ROOM_VERTEX_TOLERANCE_FEET - }); + let near = held + .iter() + .copied() + .find(|q| (q.0 - point.0).abs() <= tolerance && (q.1 - point.1).abs() <= tolerance); match near { Some(q) => (line[end], line[end + 1]) = q, None => held.push(point), diff --git a/src/research/partition_schema_mvp/rooms.rs b/src/research/partition_schema_mvp/rooms.rs index adfa6e3e..e6a20486 100644 --- a/src/research/partition_schema_mvp/rooms.rs +++ b/src/research/partition_schema_mvp/rooms.rs @@ -104,7 +104,9 @@ pub(super) fn attach_room_parameter_entries( /// Give each room the outline of its stored solid (RE-101, /// [`crate::partition_room_boundaries`]), read from the partition of the /// room's own record and kept only where it spans the record's box. A room -/// whose solid does not close keeps its box. +/// whose solid does not close is resolved from the faces up to the solid's +/// end where that spans the box (RE-316), and says so in its profile source; +/// otherwise it keeps its box. pub(super) fn attach_room_outlines( rf: &mut RevitFile, revit_version: u32, @@ -158,16 +160,33 @@ pub(super) fn attach_room_outlines( let Some(headers) = headers.get(&u64::from(id)) else { continue; }; - if let Some(profile) = prb::outline_of_solids(inflated.bytes(), headers, bbox) { + // RE-316: only a room none of whose solids the walk closes is resolved + // from the faces up to the solid's end, so a room RE-101 reads is read + // exactly as it was. + let walked = prb::outline_of_solids(inflated.bytes(), headers, bbox); + let outline = match walked { + Some(profile) => Some((profile, prb::ROOM_OUTLINE_SOURCE, true)), + None => prb::resolved_outline_of_solids(inflated.bytes(), headers, bbox).map( + |(profile, kind)| { + let source = match kind { + prb::ResolvedKind::Faces => prb::ROOM_OUTLINE_SOURCE_FACES, + prb::ResolvedKind::Approximate => prb::ROOM_OUTLINE_SOURCE_APPROXIMATE, + }; + (profile, source, false) + }, + ), + }; + if let Some((profile, source, walk)) = outline { let mut fields = profile.fields(); for (name, value) in fields.iter_mut() { if name == crate::element_record_plan_profiles::PLAN_PROFILE_SOURCE_FIELD { - *value = InstanceField::String(prb::ROOM_OUTLINE_SOURCE.into()); + *value = InstanceField::String(source.into()); } } room.fields.extend(fields); // RE-296: a room whose solid is a prism of that outline. - if ROOM_PRISM_REVIT_VERSIONS.contains(&revit_version) + if walk + && ROOM_PRISM_REVIT_VERSIONS.contains(&revit_version) && prb::prism_of_solids(inflated.bytes(), headers, bbox) { room.fields diff --git a/tools/ci/measure-reference-models.sh b/tools/ci/measure-reference-models.sh index c51071e0..c10d5b0a 100755 --- a/tools/ci/measure-reference-models.sh +++ b/tools/ci/measure-reference-models.sh @@ -133,7 +133,7 @@ IFC_SCORERS="class_sets_vs_ifc aggregates_vs_ifc global_ids_vs_ifc names_vs_ifc room_names_vs_ifc type_objects_vs_ifc layer_sets_vs_ifc element_materials_vs_ifc material_colours_vs_ifc openings_vs_ifc opening_hosts_vs_ifc opening_boxes_vs_ifc door_bodies_vs_ifc door_window_sizes_vs_ifc oriented_boxes_vs_ifc plan_profiles_vs_ifc -pipe_bodies_vs_ifc duct_bodies_vs_ifc roof_bodies_vs_ifc stair_bodies_vs_ifc ports_vs_ifc psets_vs_ifc qtos_vs_ifc saved_meshes_vs_ifc tagged_spaces_vs_ifc port_places_vs_ifc predefined_types_vs_ifc box_bodies_vs_ifc column_bodies_vs_ifc mesh_bodies_vs_ifc wall_ends_list wall_heights_vs_ifc frames_vs_ifc space_profiles_vs_ifc" +pipe_bodies_vs_ifc duct_bodies_vs_ifc roof_bodies_vs_ifc stair_bodies_vs_ifc ports_vs_ifc psets_vs_ifc qtos_vs_ifc saved_meshes_vs_ifc tagged_spaces_vs_ifc port_places_vs_ifc predefined_types_vs_ifc box_bodies_vs_ifc column_bodies_vs_ifc mesh_bodies_vs_ifc wall_ends_list wall_heights_vs_ifc frames_vs_ifc space_profiles_vs_ifc space_overlaps_vs_ifc" GLB_SCORERS="wall_bodies_vs_ifc wall_ends_cv_vs_ifc wall_layers_vs_ifc slab_layers_vs_ifc glb_material_colours_vs_ifc" read -ra MODEL_PATTERNS <<< "${MEASURE_MODELS:-}" diff --git a/tools/ci/scrub-holdout-results.sh b/tools/ci/scrub-holdout-results.sh index b875dfc1..9ece1b10 100755 --- a/tools/ci/scrub-holdout-results.sh +++ b/tools/ci/scrub-holdout-results.sh @@ -36,7 +36,9 @@ # its property set census (standard Pset_ and Qto_ names only; RE-281), # the `space profiles:` count lines of tools/re/space_profiles_vs_ifc.py # (RE-313: how many spaces pair, and their area and outline within a tolerance, -# never a GlobalId), and +# never a GlobalId), the `space overlaps:` count lines of +# tools/re/space_overlaps_vs_ifc.py (RE-316: how many rooms of each source have +# the area of Revit's upward faces, floor or every face, never a GlobalId), and # ifc.sha256, the hash of rvt-rs's own export; every other file is deleted. set -euo pipefail @@ -102,6 +104,10 @@ for dir in "$RESULTS"/holdout-*/ "$RESULTS"/bimcollab-*/; do [ -f "$f" ] || continue grep -E '^space profiles: [A-Za-z0-9 %.,:()-]+$' "$f" || true done + for f in "$dir"space_overlaps_vs_ifc.txt; do + [ -f "$f" ] || continue + grep -E "^space overlaps: (upward|downward|every) faces?, [a-z ]+: [0-9]+ paired rooms, area within 0\.1% of Revit's on [0-9]+$" "$f" || true + done for f in "$dir"storeys_vs_ifc.txt; do [ -f "$f" ] || continue grep -E "^([0-9]+ elements, [0-9]+ not on a storey|against Revit's export:| +[0-9]+ Ifc[A-Za-z0-9]+| +[0-9]+ (same storey as Revit's|on no storey; Revit's is (a storey|another container)|on a storey; Revit contains it in |a different storey from Revit's)| different, by type: Ifc[A-Za-z0-9]+ [0-9]+(, Ifc[A-Za-z0-9]+ [0-9]+)*)$" "$f" | sed 's/^/storeys: /' || true diff --git a/tools/re/space_overlaps_vs_ifc.py b/tools/re/space_overlaps_vs_ifc.py new file mode 100644 index 00000000..4f575fac --- /dev/null +++ b/tools/re/space_overlaps_vs_ifc.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""RE-316: does a room's outline cover the plan of the walls and rooms around it? + +A room's outline lies inside the walls that bound it and beside the outlines of +the rooms next to it. An outline that covers a wall's plan or another room's +is not the room's. For each room rvt-rs writes with an outline read from its +stored solid (`SpaceOutlineSource` `stored_solid`, `stored_solid_faces` or +`stored_solid_approximate`), this scorer measures + +- the area of the room's top surface that lies inside the top surfaces of the + walls on the room's level (walls that span at least a foot of its height), + each shrunk by 0.02 ft so that a room beside a wall is not inside it, and +- the area that lies inside the outlines of the other such rooms on the level, + shrunk the same way, + +against Revit's area for the same GlobalId, so a resolved outline that +disagrees with Revit's can be told from the rest by what it covers. + +The lines starting `space overlaps:` are per room, with a GlobalId, and then +counts by source. + +Usage: + + python3 tools/re/space_overlaps_vs_ifc.py +""" + +import sys + +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.util.element +import numpy as np +import shapely +from shapely.geometry import Polygon + +import plan_profiles_vs_ifc as plan +from ifc_common import site_frame + +SET = "RvtElementRecordGeometry" +ROOM_SOURCES = ("stored_solid", "stored_solid_faces", "stored_solid_approximate") +SHRINK_FEET = 0.02 +LEVEL_FEET = 1.0 + + +def bodies(path, classes, full=False): + """Every element of `classes` with its top surface (the union of its + upward faces in plan) and the heights its mesh spans, in Revit's feet.""" + f = ifcopenshell.open(path) + frame = site_frame(f) + settings = ifcopenshell.geom.settings() + settings.set("use-world-coords", True) + out = [] + for cls in classes: + try: + elements = f.by_type(cls) + except RuntimeError: + continue + for element in elements: + if not element.Representation: + continue + try: + shape = ifcopenshell.geom.create_shape(settings, element) + except Exception: + continue + verts = np.array(shape.geometry.verts, dtype=float).reshape(-1, 3) + faces = np.array(shape.geometry.faces, dtype=int).reshape(-1, 3) + if not len(verts) or not len(faces): + continue + 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() + floor = every = Polygon() + if full: + down = (length > 1e-12) & (normal[:, 2] < -0.9 * length) + floor = shapely.union_all( + [ + Polygon([tuple(p[:2]) for p in tri]).buffer(1e-7) + for tri in zip(a[down], b[down], c[down]) + ] + or [Polygon()] + ) + solid = length > 1e-12 + every = shapely.union_all( + [ + Polygon([tuple(p[:2]) for p in tri]).buffer(1e-7) + for tri in zip(a[solid], b[solid], c[solid]) + if abs(Polygon([tuple(p[:2]) for p in tri]).area) > 1e-9 + ] + or [Polygon()] + ) + out.append( + { + "id": element.GlobalId, + "surface": surface, + "floor": floor, + "every": every, + "z0": float(v[:, 2].min()), + "z1": float(v[:, 2].max()), + "props": ifcopenshell.util.element.get_psets(element).get(SET, {}) + if cls == "IfcSpace" + else {}, + } + ) + return out + + +def shrunk_union(surfaces): + shrunk = [s.buffer(-SHRINK_FEET) for s in surfaces] + shrunk = [s for s in shrunk if not s.is_empty] + return shapely.union_all(shrunk) if shrunk else Polygon() + + +def main(argv): + if len(argv) != 3: + print(__doc__, file=sys.stderr) + return 2 + ours_path, revit_path = argv[1], argv[2] + spaces = bodies(ours_path, ["IfcSpace"], full=True) + walls = bodies(ours_path, ["IfcWall", "IfcWallStandardCase"]) + revit = {r["id"]: r for r in bodies(revit_path, ["IfcSpace"], full=True)} + rooms = [] + for space in spaces: + source = space["props"].get("SpaceOutlineSource") + if source in ROOM_SOURCES and space["surface"].area > 0: + space["source"] = source + rooms.append(space) + + def on_level(a, b): + return a["z0"] < b["z1"] - LEVEL_FEET and a["z1"] > b["z0"] + LEVEL_FEET + + rows = [] + for room in rooms: + surface = room["surface"] + near_walls = [ + w["surface"] + for w in walls + if on_level(w, room) and w["surface"].intersects(surface) + ] + near_rooms = [ + o["surface"] + for o in rooms + if o is not room and on_level(o, room) and o["surface"].intersects(surface) + ] + area = float(surface.area) + wall_over = float(surface.intersection(shrunk_union(near_walls)).area) + room_over = float(surface.intersection(shrunk_union(near_rooms)).area) + theirs = revit.get(room["id"]) + rows.append( + { + "id": room["id"], + "source": room["source"], + "area": area, + "walls": wall_over, + "rooms": room_over, + "up": area, + "floor": float(room["floor"].area), + "every": float(room["every"].area), + "revit": float(theirs["surface"].area) if theirs else None, + "revit_floor": float(theirs["floor"].area) if theirs else None, + "revit_every": float(theirs["every"].area) if theirs else None, + } + ) + + for source in ROOM_SOURCES: + mine = [r for r in rows if r["source"] == source] + if not mine: + continue + wall_share = [r["walls"] / r["area"] for r in mine] + room_share = [r["rooms"] / r["area"] for r in mine] + print( + f"space overlaps: {source.replace('_', ' ')}: {len(mine)} rooms; " + f"walls: above 0.1% of the area on {sum(s > 0.001 for s in wall_share)}, " + f"above 1% on {sum(s > 0.01 for s in wall_share)}, " + f"above 5% on {sum(s > 0.05 for s in wall_share)}, " + f"largest {100 * max(wall_share):.2f}%; " + f"other rooms: above 0.1% on {sum(s > 0.001 for s in room_share)}, " + f"above 1% on {sum(s > 0.01 for s in room_share)}, " + f"above 5% on {sum(s > 0.05 for s in room_share)}, " + f"largest {100 * max(room_share):.2f}%" + ) + paired = [r for r in rows if r["revit"] is not None and r["up"] > 0] + for name, ours_key, revit_key in ( + ("upward faces", "up", "revit"), + ("downward faces", "floor", "revit_floor"), + ("every face", "every", "revit_every"), + ): + for source in ROOM_SOURCES: + mine = [r for r in paired if r["source"] == source] + if not mine: + continue + equal = sum( + abs(r[ours_key] - r[revit_key]) <= 0.001 * max(r[revit_key], 1e-9) for r in mine + ) + print( + f"space overlaps: {name}, {source.replace('_', ' ')}: " + f"{len(mine)} paired rooms, area within 0.1% of Revit's on {equal}" + ) + listed = [r for r in rows if r["source"] != "stored_solid"] + listed += sorted( + (r for r in rows if r["source"] == "stored_solid"), + key=lambda r: -(r["walls"] + r["rooms"]) / r["area"], + )[:8] + for r in listed: + revit_text = ( + "no Revit space" + if r["revit"] is None + else f"Revit up {r['revit']:.2f}, floor {r['revit_floor']:.2f}, every face {r['revit_every']:.2f} ft2" + ) + print( + f" {r['id']}: {r['source']}, up {r['up']:.2f}, floor {r['floor']:.2f}, " + f"every face {r['every']:.2f} ft2 ({revit_text}), " + f"inside walls {r['walls']:.2f} ft2 ({100 * r['walls'] / r['area']:.2f}%), " + f"inside other rooms {r['rooms']:.2f} ft2 ({100 * r['rooms'] / r['area']:.2f}%)" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))