From e59213a115e950a1120adac137920183d7307a56 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:00:41 -0500 Subject: [PATCH 1/3] fix(saved-graphics): mesh a cylinder whose saved basis is left-handed, its normal turned as on a plane (#1247) --- src/research/native_saved_mesh.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/research/native_saved_mesh.rs b/src/research/native_saved_mesh.rs index f0500313c..10a9233b8 100644 --- a/src/research/native_saved_mesh.rs +++ b/src/research/native_saved_mesh.rs @@ -735,10 +735,9 @@ pub fn cylinder_face( x_vec[2] * y_vec[0] - x_vec[0] * y_vec[2], x_vec[0] * y_vec[1] - x_vec[1] * y_vec[0], ]; - ensure!( - dot(cross_xy, z_vec) > 1. - 1e-7, - "left-handed cylinder basis" - ); + // A left-handed basis is the same cylinder: only the surface's own + // normal, ∂P/∂u × ∂P/∂v as on a plane, turns inward (#1247). + let outward = if dot(cross_xy, z_vec) > 0. { 1. } else { -1. }; let envelope = surface.fields["m_Envelope"]["m_corners"] .as_array() .context("cylinder envelope")?; @@ -901,14 +900,12 @@ pub fn cylinder_face( for &u in u_values.iter().take(nu + 1) { vertices.push(eval(u, v)); let (s, c) = u.sin_cos(); - let mut n = [ - c * x_vec[0] + s * y_vec[0], - c * x_vec[1] + s * y_vec[1], - c * x_vec[2] + s * y_vec[2], + let sign = if orient { outward } else { -outward }; + let n = [ + sign * (c * x_vec[0] + s * y_vec[0]), + sign * (c * x_vec[1] + s * y_vec[1]), + sign * (c * x_vec[2] + s * y_vec[2]), ]; - if !orient { - n = [-n[0], -n[1], -n[2]]; - } normals.push(n); } } From 148f4cd80da04845495f80b46ae63138ed6dee0d Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:13:39 -0500 Subject: [PATCH 2/3] docs(saved-graphics): RE-301's report and changelog entry for left-handed cylinders (#1247) --- .../fixed/1247-left-handed-cylinders.md | 7 ++ .../RE-301-left-handed-cylinders.md | 66 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 changelog.d/fixed/1247-left-handed-cylinders.md create mode 100644 reports/element-framing/RE-301-left-handed-cylinders.md diff --git a/changelog.d/fixed/1247-left-handed-cylinders.md b/changelog.d/fixed/1247-left-handed-cylinders.md new file mode 100644 index 000000000..6bc957e4e --- /dev/null +++ b/changelog.d/fixed/1247-left-handed-cylinders.md @@ -0,0 +1,7 @@ +- **Saved meshes read cylinders whose saved basis is left-handed.** Such a + face was rejected, and with it the whole element, which kept its box. + It is the same cylinder with its surface normal turned. On the BIM4LCA + office 100 more bodies are drawn from their saved meshes (102 at permit), + among them doors that the default export draws, 66 exact to 0.001 ft + against Revit's export and 93 within 0.01 ft. Snowdon Towers' + architectural sample gains 172 (#1247, RE-301). diff --git a/reports/element-framing/RE-301-left-handed-cylinders.md b/reports/element-framing/RE-301-left-handed-cylinders.md new file mode 100644 index 000000000..b3f1d9789 --- /dev/null +++ b/reports/element-framing/RE-301-left-handed-cylinders.md @@ -0,0 +1,66 @@ +# RE-301 — A saved cylinder with a left-handed basis is the same cylinder + +**Date:** 2026-11-02 +**Issues:** #1247 (saved meshes that v0.6.1 drew and v0.12.0 draws as boxes), #255; follows RE-224 +**Runs:** Measure [41540910235](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41540910235) (`probe_re224_saved_mesh_status` on `main` at 895109d9: the census below), Measure [41541977595](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41541977595) (this change at e59213a1 against `main`, `--saved-meshes`, `mesh_bodies_vs_ifc`) +**Result:** positive. `native_saved_mesh::cylinder_face` rejected a `CylSurf` whose `m_xVec × m_yVec` is `−m_zVec`, which keeps the whole element's box (RE-224). Such a basis describes the same cylinder; only the parametric normal ∂P/∂u × ∂P/∂v points inward instead of outward. Reading it as on a plane (the normal is the parametric one, turned by `m_orientFlag` and the face flag) restores 100 bodies on the BIM4LCA office, 66 of them exact to 0.001 ft against Revit's export, and none further from Revit's than 0.011 ft. + +## 1. Why meshes became boxes + +#1247 reports that v0.12.0 draws as 12-triangle boxes many elements v0.6.1 drew from their saved meshes (`--saved-meshes`). Measure [41537426493](https://github.com/DrunkOnJava/rvt-rs/actions/runs/41537426493) reproduces it on our files (v0.6.1 against v0.12.0, both with `--saved-meshes`): Snowdon Towers electrical (Revit 2024, an Autodesk sample) draws 1,420 bodies from saved meshes with v0.6.1 and 907 with v0.12.0, the architectural sample keeps 754 boxes, the office 357. Every element lost is still a record box in v0.12.0, kept by RE-224's gate: some part of its saved graphics did not decode. The census of what did not decode, on the office, RE1 and the 2024 samples (3,020 such elements): + +| what did not decode | elements | with nothing else | +|---|---:|---:| +| graphics class `GImposter` | 1,813 | 115 | +| `GPolyMesh` normal binding (always with `GImposter`) | 1,463 | 0 | +| left-handed cylinder basis | 526 | 484 | +| non-rectangular cylinder trim | 473 | 222 | +| `GEllipse` profile curve | 405 | 6 | +| graphics class `GBiFlipControl` | 117 | 52 | + +This report takes the left-handed cylinder. `GImposter`, which holds every Snowdon Towers light fixture, is RE-300. + +## 2. The reading + +`cylinder_face` evaluates `P(u, v) = centre + r(cos u·x + sin u·y) + v·z`, valid for any orthonormal basis. ∂P/∂u × ∂P/∂v is `r(cos u·x + sin u·y)` when `x × y = z` and its negative when `x × y = −z`. The grid's triangles are wound along ∂P/∂u × ∂P/∂v, turned by the orientation as before, so they need no change; the per-vertex normals now take the same sign, as a plane's `x × y` does. Vertex positions do not depend on the basis's handedness at all. + +## 3. Measured + +Measure 41541977595, this change against `main`, both with `--saved-meshes`: + +| file | bodies drawn from saved meshes, before → after | kept as boxes, before → after | +|---|---|---| +| BIM4LCA office as built (2024) | 10,781 → 10,881 | 357 → 322 | +| BIM4LCA office at permit (2024) | 10,896 → 10,998 | 359 → 322 | +| RE1 Architecture (2025) | 51 → 52 | 5 → 4 | +| Snowdon Towers architectural (2024 sample) | 1,799 → 1,971 | 754 → 646 | +| Snowdon Towers plumbing (2024 sample) | 2,500 → 2,516 | 137 → 121 | +| rme basic and advanced (2024 samples) | 5,074 → 5,096 and 5,078 → 5,099 | 566 → 551 | +| rac basic (2024 sample) | 270 → 284 | 49 → 49 | +| rst basic (2024 sample) | 63 → 67 | 0 → 0 | +| Core Interior, RE1 Electrical, Mechanical, Plumbing | unchanged | unchanged | + +Snowdon Towers electrical keeps 907; its count of partial meshes rises from 513 to 556, because 43 fixtures whose only surfaces were left-handed cylinders had no mesh at all before and now have one that their `GImposter` leaves partial. + +Against Revit's own export (`mesh_bodies_vs_ifc`, the bodies scored): + +| file | scored, before → after | exact (0.001 ft) | within 0.01 ft | volume within 1% | +|---|---|---|---|---| +| office as built | 1,352 → 1,452 | 1,294 → 1,360 | 1,378 → 1,471 | 1,340 → 1,434 | +| office at permit | 1,438 → 1,540 | 1,376 → 1,444 | 1,460 → 1,555 | 1,425 → 1,521 | +| RE1 Architecture | 51 → 52 | 45 → 45 | 49 → 50 | 51 → 52 | + +The office's doors drawn from saved meshes rise from 46 scored to 114, all 114 exact; the default export draws them, so this change reaches it as well as `--saved-meshes`. The new bodies that are not within 0.01 ft are seven "Sink Kitchen-Single" at 0.0106 ft (Revit's vertices within 0.0040 ft of rvt-rs's surface, volume within 1.3%), a tessellation difference; RE1's new chair is within 0.0021 ft. The IFC4 schema check is unchanged but for 100 fewer swept solids. + +## 4. Not claimed + +- The other kinds in §1; `GImposter` is RE-300. +- The normals' direction is not scored: the scorers test vertex positions and volume. + +## 5. Reproduce + +```bash +gh workflow run measure.yml --ref main -f ref=e59213a1 -f base=main -f ifc_flags=--saved-meshes \ + -f nordic=true -f samples=true -f sample_years="2024 2024" \ + -f models="core-interior re1-* nordic-office-* sample-2024-*" -f scorers="mesh_bodies_vs_ifc schema" +``` From 9ae128502d6dac1f245c46ca9db7dad5d9be15c9 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:29:03 -0500 Subject: [PATCH 3/3] docs(saved-graphics): RE-301's corrected 0.01 ft and door counts, its size cost, and the reconnaissance addendum (#1247) --- docs/rvt-moat-break-reconnaissance.md | 4 ++++ reports/element-framing/RE-301-left-handed-cylinders.md | 8 +++++--- src/research/native_saved_mesh.rs | 4 ++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/rvt-moat-break-reconnaissance.md b/docs/rvt-moat-break-reconnaissance.md index c9480b8d7..fb6e663cb 100644 --- a/docs/rvt-moat-break-reconnaissance.md +++ b/docs/rvt-moat-break-reconnaissance.md @@ -2244,6 +2244,10 @@ Revit's export writes a floor's `Qto_SlabBaseQuantities.NetVolume` as the volume Revit's export writes a floor's `Qto_SlabBaseQuantities.GrossArea` as the area of the profile its slab extrudes, its sketch outline less its holes: equal to rvt-rs's profile on all 40 of the BIM4LCA office's slabs drawn from their record box, where the slab's other quantities follow its body less often (a sloped slab's record box is deeper than its layers). Stair landings' slabs get none. rvt-rs writes it on each floor drawn as one extrusion: 31 equal on the office, none different. Report: `reports/element-framing/RE-249-slab-gross-area.md`. +## Addendum — RE-301 a saved cylinder with a left-handed basis (2026-11-02) + +A saved `CylSurf` whose `m_xVec × m_yVec` is `−m_zVec` is the same cylinder; only its parametric normal ∂P/∂u × ∂P/∂v points inward. Read as a plane is (the normal is the parametric one, turned by the orientation), such faces no longer keep their whole element's box: 100 more saved-mesh bodies on the BIM4LCA office, 66 exact to 0.001 ft against Revit's export, and 172 more on Snowdon Towers' architectural sample. Report: `reports/element-framing/RE-301-left-handed-cylinders.md`. + ## Addendum — RE-298 a flat roof's slab quantities, and a level floor's Depth (2026-10-30) Revit's export writes a slab's `GrossVolume` as its `GrossArea` times its `Depth` on all 42 of the BIM4LCA office's slabs with a volume. A flat roof written as a slab (all 10, `Basic Roof`) has the extrusion's depth, its layers' total, however many there are (3 for `YP-3`, 5 for `YP-2`, 10 for `YP-1`), its `GrossArea` is its sketch's profile's area, and its `NetVolume` is the area times the depth except on the one a hatch cuts, which holds four `WallCutoutGStep`s; rvt-rs writes these. A level floor of several layers (the 10 of types `VP-1`, `VP-2` and `AP-1`) has its last layer's thickness as `Depth` in the office's export (24.0.5), but Revit 26.4's export of the held-out Revit 2026 house writes another value on 2 such slabs, so rvt-rs writes none (the maintainer's standing rule, 2026-10-31). Report: `reports/element-framing/RE-298-slab-depth-roofs.md`. diff --git a/reports/element-framing/RE-301-left-handed-cylinders.md b/reports/element-framing/RE-301-left-handed-cylinders.md index b3f1d9789..d2cfca76a 100644 --- a/reports/element-framing/RE-301-left-handed-cylinders.md +++ b/reports/element-framing/RE-301-left-handed-cylinders.md @@ -46,11 +46,13 @@ Against Revit's own export (`mesh_bodies_vs_ifc`, the bodies scored): | file | scored, before → after | exact (0.001 ft) | within 0.01 ft | volume within 1% | |---|---|---|---|---| -| office as built | 1,352 → 1,452 | 1,294 → 1,360 | 1,378 → 1,471 | 1,340 → 1,434 | -| office at permit | 1,438 → 1,540 | 1,376 → 1,444 | 1,460 → 1,555 | 1,425 → 1,521 | +| office as built | 1,352 → 1,452 | 1,294 → 1,360 | 1,345 → 1,438 | 1,340 → 1,434 | +| office at permit | 1,438 → 1,540 | 1,376 → 1,444 | 1,427 → 1,522 | 1,425 → 1,521 | | RE1 Architecture | 51 → 52 | 45 → 45 | 49 → 50 | 51 → 52 | -The office's doors drawn from saved meshes rise from 46 scored to 114, all 114 exact; the default export draws them, so this change reaches it as well as `--saved-meshes`. The new bodies that are not within 0.01 ft are seven "Sink Kitchen-Single" at 0.0106 ft (Revit's vertices within 0.0040 ft of rvt-rs's surface, volume within 1.3%), a tessellation difference; RE1's new chair is within 0.0021 ft. The IFC4 schema check is unchanged but for 100 fewer swept solids. +The office's doors drawn from saved meshes rise from 48 scored (46 at permit) to 114, all 114 exact; the default export draws them, so this change reaches it as well as `--saved-meshes`. The new bodies that are not within 0.01 ft are seven "Sink Kitchen-Single" at 0.0106 ft (Revit's vertices within 0.0040 ft of rvt-rs's surface, volume within 1.3%), a tessellation difference; RE1's new chair is within 0.0021 ft. The IFC4 schema check is unchanged but for 100 fewer swept solids. + +The meshes cost size: with `--saved-meshes` the office as built goes from 331,302 to 447,784 triangles and its IFC from 70.6 MB to 75.1 MB, and Snowdon Towers architectural from 648,933 to 974,573 triangles (12.8 MB more). ## 4. Not claimed diff --git a/src/research/native_saved_mesh.rs b/src/research/native_saved_mesh.rs index 10a9233b8..ce298d8e7 100644 --- a/src/research/native_saved_mesh.rs +++ b/src/research/native_saved_mesh.rs @@ -737,7 +737,7 @@ pub fn cylinder_face( ]; // A left-handed basis is the same cylinder: only the surface's own // normal, ∂P/∂u × ∂P/∂v as on a plane, turns inward (#1247). - let outward = if dot(cross_xy, z_vec) > 0. { 1. } else { -1. }; + let handed = if dot(cross_xy, z_vec) > 0. { 1. } else { -1. }; let envelope = surface.fields["m_Envelope"]["m_corners"] .as_array() .context("cylinder envelope")?; @@ -900,7 +900,7 @@ pub fn cylinder_face( for &u in u_values.iter().take(nu + 1) { vertices.push(eval(u, v)); let (s, c) = u.sin_cos(); - let sign = if orient { outward } else { -outward }; + let sign = if orient { handed } else { -handed }; let n = [ sign * (c * x_vec[0] + s * y_vec[0]), sign * (c * x_vec[1] + s * y_vec[1]),