ifc-lite-geometry 14.0.0

Geometry processing and mesh generation for IFC models
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

//! Tests for the zone split (#2508 item 2).
//!
//! Every expected number here is hand-computable from the fixture, which is
//! what #2508's verification bar asks for: "a box wall crossing one boundary at
//! a known fraction, a wall crossing two boundaries, an element wholly inside,
//! an element wholly outside, an element touching exactly at a boundary plane".
//! A test that only asserted "the pieces sum to the whole" would pass on a
//! split that put every cubic metre in the wrong zone.

use super::*;
use crate::kernel::arrangement::box_mesh;

/// A 6 x 1 x 1 m wall from x = 0 to x = 6, so a boundary at x = c leaves
/// exactly `c` m3 below it. Volume 6.
fn wall() -> Vec<Tri> {
    box_mesh([0.0, 0.0, 0.0], [6.0, 1.0, 1.0])
}

/// A zone spanning `[x0, x1]` in x and covering the wall entirely in y and z.
fn slab(x0: f64, x1: f64) -> ZoneShape {
    ZoneShape::Box(ZoneBox {
        center: [(x0 + x1) / 2.0, 0.5, 0.5],
        size: [x1 - x0, 4.0, 4.0],
        rotation_y: 0.0,
    })
}

/// `split_mesh_by_zones` for a host known to enclose volume.
fn split_by(host: &[Tri], zones: &[ZoneShape]) -> ZoneSplit {
    split_mesh_by_zones(host, zones).expect("this host encloses volume")
}

fn volume_of(split: &ZoneSplit, zone: Option<usize>) -> f64 {
    split
        .pieces
        .iter()
        .filter(|p| p.zone == zone)
        .map(|p| p.volume)
        .sum()
}

/// Tight, because these are exact-arithmetic cuts of axis-aligned boxes: the
/// residue is f64 accumulation in the divergence sum and nothing else.
const EPS: f64 = 1e-9;

#[test]
fn wall_crossing_one_boundary_splits_at_the_known_fraction() {
    let split = split_by(&wall(), &[slab(-1.0, 2.0), slab(2.0, 7.0)]);

    assert!((split.whole_volume - 6.0).abs() < EPS, "whole {}", split.whole_volume);
    assert!((volume_of(&split, Some(0)) - 2.0).abs() < EPS, "{:?}", split.pieces.iter().map(|p| (p.zone, p.volume)).collect::<Vec<_>>());
    assert!((volume_of(&split, Some(1)) - 4.0).abs() < EPS);
    assert!(split.sum_error_rel() < 1e-12, "sum error {}", split.sum_error_rel());
}

#[test]
fn wall_crossing_two_boundaries_gives_three_pieces() {
    let split = split_by(&wall(), &[slab(-1.0, 1.5), slab(1.5, 4.5), slab(4.5, 7.0)]);

    assert!((volume_of(&split, Some(0)) - 1.5).abs() < EPS);
    assert!((volume_of(&split, Some(1)) - 3.0).abs() < EPS);
    assert!((volume_of(&split, Some(2)) - 1.5).abs() < EPS);
    assert!(volume_of(&split, None) < EPS, "nothing should be left over");
    assert!(split.sum_error_rel() < 1e-12);
}

#[test]
fn an_element_wholly_inside_one_zone_is_one_piece_of_the_whole() {
    let split = split_by(&wall(), &[slab(-1.0, 7.0)]);

    assert_eq!(split.pieces.len(), 1);
    assert_eq!(split.pieces[0].zone, Some(0));
    assert!((split.pieces[0].volume - 6.0).abs() < EPS);
}

#[test]
fn an_element_in_no_zone_becomes_the_remainder_rather_than_vanishing() {
    // The zone is far away in x. Returning no pieces here would say the
    // element ceased to exist, which is the one answer a split must never give.
    let split = split_by(&wall(), &[slab(100.0, 110.0)]);

    assert_eq!(split.pieces.len(), 1);
    assert_eq!(split.pieces[0].zone, None);
    assert!((split.pieces[0].volume - 6.0).abs() < EPS);
    assert!(split.sum_error_rel() < 1e-12);
}

#[test]
fn part_of_an_element_outside_every_zone_survives_as_the_remainder() {
    let split = split_by(&wall(), &[slab(-1.0, 2.0)]);

    assert!((volume_of(&split, Some(0)) - 2.0).abs() < EPS);
    assert!((volume_of(&split, None) - 4.0).abs() < EPS);
    assert!(split.sum_error_rel() < 1e-12);
}

#[test]
fn an_element_ending_exactly_on_a_boundary_plane_produces_no_second_piece() {
    // The wall ends at x = 6 and the second zone starts there. This is the
    // common case in takt planning, where sets tile a building on shared
    // planes, and it is why v1's straddle epsilon is negative. A zero-thickness
    // "piece" here would be a solid of no volume for a user to wonder about.
    let split = split_by(&wall(), &[slab(-1.0, 6.0), slab(6.0, 9.0)]);

    assert_eq!(split.pieces.len(), 1, "{:?}", split.pieces.iter().map(|p| (p.zone, p.volume)).collect::<Vec<_>>());
    assert_eq!(split.pieces[0].zone, Some(0));
    assert!((split.pieces[0].volume - 6.0).abs() < EPS);
}

#[test]
fn a_rotated_zone_cuts_where_its_own_axes_are() {
    // A zone rotated 45 degrees about Y, centred on the wall's own centre line
    // at x = 3. Its local +x axis points along (1, 0, 1)/sqrt(2), and the plane
    // through the centre normal to that axis crosses the wall's centre line at
    // x = 3, so it takes exactly the half of the wall below x = 3 minus what
    // the rotation trims in z. The box is made long enough (8 m local x, 8 m
    // local z) that the only face cutting the wall is the one at local
    // x = -4... which the placement below puts far outside, so the whole wall
    // is inside. What this pins is that ROTATION IS APPLIED AT ALL: with
    // rotation ignored, the same box is an axis-aligned 8 x 4 x 0.5 slab whose
    // z extent (0.5 m, centred at z = 0.5) still covers the wall, so a naive
    // implementation passes. Hence the second, tighter zone below.
    let inside = ZoneShape::Box(ZoneBox { center: [3.0, 0.5, 0.5], size: [8.0, 4.0, 8.0], rotation_y: 45f64.to_radians() });
    let split = split_by(&wall(), &[inside]);
    assert!((volume_of(&split, Some(0)) - 6.0).abs() < EPS);

    // A thin blade, 0.4 m across its local x, standing at 45 degrees through
    // the wall at x = 3. Rotated, it sweeps a diagonal band across the wall's
    // 1 m depth: the band's extent along x is 0.4*sqrt(2) plus the 1 m of z
    // depth projected, i.e. the intersection is a parallelogram prism of area
    // (0.4/cos45) * 1 in the x/z plane... which is 0.5656854 m2, times the 1 m
    // height = 0.5656854 m3. Unrotated, the same blade would take 0.4 m3.
    let blade = ZoneShape::Box(ZoneBox { center: [3.0, 0.5, 0.5], size: [0.4, 4.0, 8.0], rotation_y: 45f64.to_radians() });
    let split = split_by(&wall(), &[blade]);
    let expected = 0.4 * 2f64.sqrt();
    assert!(
        (volume_of(&split, Some(0)) - expected).abs() < 1e-6,
        "rotated blade took {} m3, expected {}",
        volume_of(&split, Some(0)),
        expected,
    );
    assert!(split.sum_error_rel() < 1e-9, "sum error {}", split.sum_error_rel());
}

#[test]
fn a_zone_bigger_than_the_element_takes_all_of_it_and_leaves_no_remainder() {
    let split = split_by(
        &wall(),
        &[ZoneShape::Box(ZoneBox { center: [3.0, 0.5, 0.5], size: [100.0, 100.0, 100.0], rotation_y: 0.0 })],
    );
    assert_eq!(split.pieces.len(), 1);
    assert!((split.pieces[0].volume - 6.0).abs() < EPS);
}

#[test]
fn a_zero_size_zone_takes_nothing_and_does_not_crash() {
    let split = split_by(
        &wall(),
        &[ZoneShape::Box(ZoneBox { center: [3.0, 0.5, 0.5], size: [0.0, 0.0, 0.0], rotation_y: 0.0 })],
    );
    assert!(volume_of(&split, Some(0)) < EPS);
    assert!((volume_of(&split, None) - 6.0).abs() < EPS);
}

#[test]
fn a_host_without_volume_is_refused_not_split_perfectly() {
    // An empty host used to come back as a split with one zero-triangle
    // "remainder", `sum_error_rel() == 0.0` and `remainder_failed == false`:
    // every signal the wasm boundary forwards read as a perfect split (the
    // predecessor of this test asserted exactly that). Mutation: drop the
    // `is_empty` and `NO_VOLUME_REL` guards in `split_mesh_by_zones` and every
    // case below becomes `Some` with a perfect-looking report.
    assert!(split_mesh_by_zones(&[], &[slab(0.0, 1.0)]).is_none(), "empty host");
    // A non-empty shell that encloses nothing is the same outcome: a flat
    // double-sided sheet has a divergence sum of exactly zero.
    let sheet: Vec<Tri> = vec![
        [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
        [[0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]],
    ];
    assert!(split_mesh_by_zones(&sheet, &[slab(0.0, 1.0)]).is_none(), "zero-volume sheet");
    // Tilted and away from the origin, the same sheet sums to rounding residue
    // rather than to zero, which an absolute zero test lets through as a
    // perfect split. Mutation: compare against `f64::MIN_POSITIVE` again and
    // this case reads `Some`.
    let residue = tilted_double_sided_sheet();
    assert!(
        split_mesh_by_zones(&residue, &[slab(0.0, 10.0)]).is_none(),
        "a tilted double-sided sheet encloses no volume"
    );
}

/// A 6 by 6 grid on the plane z = 0.3x + 0.1y + 2.7, offset from the origin,
/// front triangles then the same triangles reversed.
fn tilted_double_sided_sheet() -> Vec<Tri> {
    let p = |i: usize, j: usize| {
        let (x, y) = (3.1 + 0.37 * i as f64, -1.3 + 0.41 * j as f64);
        [x, y, 0.3 * x + 0.1 * y + 2.7]
    };
    let mut front = Vec::new();
    for i in 0..6 {
        for j in 0..6 {
            front.push([p(i, j), p(i + 1, j), p(i + 1, j + 1)]);
            front.push([p(i, j), p(i + 1, j + 1), p(i, j + 1)]);
        }
    }
    let back: Vec<Tri> = front.iter().map(|t| [t[0], t[2], t[1]]).collect();
    front.extend(back);
    front
}

#[test]
fn an_inward_wound_host_still_yields_positive_pieces() {
    // Real IFC winding is not reliably outward (see `orient_outward`'s doc). A
    // split that reported negative volumes for an inward-wound wall would be
    // reporting the winding, not the geometry.
    let mut flipped = wall();
    for t in &mut flipped {
        t.swap(1, 2);
    }
    let split = split_by(&flipped, &[slab(-1.0, 2.0), slab(2.0, 7.0)]);

    assert!(split.whole_volume > 0.0, "whole {}", split.whole_volume);
    assert!((volume_of(&split, Some(0)) - 2.0).abs() < EPS);
    assert!((volume_of(&split, Some(1)) - 4.0).abs() < EPS);
}

#[test]
fn overlapping_zones_are_reported_by_the_sum_invariant_rather_than_hidden() {
    // v1 does not forbid overlapping zones. Both pieces are individually
    // correct and together they double-count the overlap, so the honest signal
    // is the invariant failing loudly rather than a plausible-looking split.
    let split = split_by(&wall(), &[slab(-1.0, 4.0), slab(2.0, 7.0)]);

    assert!((volume_of(&split, Some(0)) - 4.0).abs() < EPS);
    assert!((volume_of(&split, Some(1)) - 4.0).abs() < EPS);
    assert!(
        split.sum_error_rel() > 0.1,
        "overlap should be visible in the sum, got {}",
        split.sum_error_rel(),
    );
}

#[test]
fn the_pieces_are_closed_solids_and_not_merely_clipped_shells() {
    // A volume measured about the origin and about a far-away point agree only
    // for a CLOSED surface: the divergence sum of an open shell depends on the
    // point it is measured about. This is what distinguishes a real split from
    // a set of cut-open faces that happen to sum correctly.
    let split = split_by(&wall(), &[slab(-1.0, 2.0), slab(2.0, 7.0)]);
    for piece in &split.pieces {
        let shifted: Vec<Tri> = piece
            .tris
            .iter()
            .map(|t| t.map(|p| [p[0] + 1000.0, p[1] - 500.0, p[2] + 250.0]))
            .collect();
        let about_far = crate::kernel::signed_volume::signed_volume_of(&shifted);
        assert!(
            (about_far - piece.volume).abs() < 1e-6,
            "piece {:?} is not closed: {} about the origin, {} about a far point",
            piece.zone,
            piece.volume,
            about_far,
        );
    }
}

#[test]
fn a_prism_zone_cuts_by_its_polygon_and_not_by_its_bounding_box() {
    // A triangle in plan whose bounding box covers the whole wall but which
    // itself covers a known fraction of it. The wall spans x = 0..6, z = 0..1.
    // The triangle (0,0) - (6,0) - (0,1) cuts the wall's plan rectangle along
    // the diagonal, taking exactly half of its 6 m2 footprint, so half its
    // volume. A box-shaped implementation would take all 6 m3.
    let prism = ZoneShape::Prism {
        footprint: vec![[0.0, 0.0], [6.0, 0.0], [0.0, 1.0]],
        min_y: -1.0,
        max_y: 2.0,
    };
    let split = split_by(&wall(), &[prism]);

    assert!(
        (volume_of(&split, Some(0)) - 3.0).abs() < 1e-6,
        "prism took {} m3, expected 3",
        volume_of(&split, Some(0)),
    );
    assert!((volume_of(&split, None) - 3.0).abs() < 1e-6, "remainder {}", volume_of(&split, None));
    assert!(split.sum_error_rel() < 1e-9, "sum error {}", split.sum_error_rel());
}

#[test]
fn two_prisms_tiling_the_plan_split_the_element_between_them() {
    let lower = ZoneShape::Prism {
        footprint: vec![[-1.0, -1.0], [8.0, -1.0], [8.0, 2.0]],
        min_y: -1.0,
        max_y: 2.0,
    };
    let upper = ZoneShape::Prism {
        footprint: vec![[-1.0, -1.0], [8.0, 2.0], [-1.0, 2.0]],
        min_y: -1.0,
        max_y: 2.0,
    };
    let split = split_by(&wall(), &[lower, upper]);

    assert!(volume_of(&split, None) < 1e-6, "the tiling should leave no remainder");
    assert!(split.sum_error_rel() < 1e-9, "sum error {}", split.sum_error_rel());
    // Neither zone took the lot, or the test would pass on a splitter that
    // ignored the second polygon.
    assert!(volume_of(&split, Some(0)) > 0.5 && volume_of(&split, Some(1)) > 0.5);
}

#[test]
fn a_prism_with_too_few_points_takes_nothing_rather_than_panicking() {
    let degenerate = ZoneShape::Prism { footprint: vec![[0.0, 0.0], [1.0, 0.0]], min_y: -1.0, max_y: 2.0 };
    let split = split_by(&wall(), &[degenerate]);
    assert!(volume_of(&split, Some(0)) < EPS);
    assert!((volume_of(&split, None) - 6.0).abs() < EPS);
}

/// A triangle's vertices in a rotation-independent, winding-SENSITIVE key, so
/// two copies of the same face wound oppositely hash differently.
fn oriented_key(t: &Tri) -> String {
    let start = (0..3)
        .min_by(|&a, &b| {
            let (x, y) = (t[a], t[b]);
            x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal)
        })
        .unwrap_or(0);
    (0..3)
        .map(|i| {
            let p = t[(start + i) % 3];
            format!("{:.9},{:.9},{:.9}", p[0], p[1], p[2])
        })
        .collect::<Vec<_>>()
        .join("|")
}

/// The first face whose opposite winding is also present, for a failure message
/// that points at the membrane instead of at triangle zero.
fn first_membrane_face(tris: &[Tri]) -> Option<Tri> {
    use std::collections::HashSet;
    let forward: HashSet<String> = tris.iter().map(oriented_key).collect();
    tris.iter()
        .find(|t| forward.contains(&oriented_key(&[t[0], t[2], t[1]])))
        .copied()
}

/// Faces that appear both ways round in one solid: a zero-volume membrane.
fn back_to_back_pairs(tris: &[Tri]) -> usize {
    use std::collections::HashSet;
    let forward: HashSet<String> = tris.iter().map(oriented_key).collect();
    tris.iter()
        .filter(|t| {
            let flipped = [t[0], t[2], t[1]];
            forward.contains(&oriented_key(&flipped))
        })
        .count()
}

#[test]
fn a_tiling_leaves_no_membrane_inside_the_remainder() {
    // Two zones sharing the plane x = 2, covering only part of the wall, so the
    // remainder is a real solid rather than a dropped sliver.
    //
    // `difference_all` documents that its cutters must be pairwise disjoint,
    // and a tiling zone set is precisely the case that violates it: the two
    // coincident, opposite-wound cutter faces at x = 2 are each classified only
    // against the host, so BOTH survive into the remainder as a zero-volume
    // membrane. Volume cannot see it (the pair cancels), which is why the
    // assertion is on the faces rather than on any number.
    let split = split_by(&wall(), &[slab(0.0, 2.0), slab(2.0, 4.0)]);

    let remainder = split
        .pieces
        .iter()
        .find(|p| p.zone.is_none())
        .expect("the wall's x = 4..6 tail must survive as the remainder");
    assert!((remainder.volume - 2.0).abs() < EPS, "remainder volume {}", remainder.volume);
    let membranes = back_to_back_pairs(&remainder.tris);
    assert_eq!(
        membranes,
        0,
        "the remainder carries {} membrane faces, e.g. {:?}",
        membranes,
        // The FIRST triangle whose flip is also present, rather than the first
        // triangle of the piece: a message that always names triangle 0 tells
        // the next reader nothing about where the membrane is.
        first_membrane_face(&remainder.tris),
    );
}

#[test]
fn a_prism_tiling_leaves_no_membrane_either() {
    let lower = ZoneShape::Prism {
        footprint: vec![[-1.0, -1.0], [3.0, -1.0], [3.0, 2.0], [-1.0, 2.0]],
        min_y: -1.0,
        max_y: 2.0,
    };
    let upper = ZoneShape::Prism {
        footprint: vec![[3.0, -1.0], [5.0, -1.0], [5.0, 2.0], [3.0, 2.0]],
        min_y: -1.0,
        max_y: 2.0,
    };
    let split = split_by(&wall(), &[lower, upper]);
    let remainder = split
        .pieces
        .iter()
        .find(|p| p.zone.is_none())
        .expect("x = 5..6 must survive as the remainder");
    assert_eq!(back_to_back_pairs(&remainder.tris), 0);
    assert!(split.sum_error_rel() < 1e-9, "sum error {}", split.sum_error_rel());
}

#[test]
fn a_zone_the_element_only_abuts_is_not_subtracted_from_the_remainder() {
    // The second zone starts exactly where the wall ends, so it takes nothing.
    // Subtracting it anyway would hand the arrangement an exactly-coplanar
    // operand with no intersection to show for it, and a refusal there
    // (`difference_all` returns None) would lose the remainder entirely.
    let split = split_by(&wall(), &[slab(0.0, 2.0), slab(6.0, 9.0)]);

    let remainder = split.pieces.iter().find(|p| p.zone.is_none());
    assert!(remainder.is_some(), "the x = 2..6 remainder was lost");
    assert!((remainder.unwrap().volume - 4.0).abs() < EPS);
    assert!(split.sum_error_rel() < 1e-12);
}