ifc-lite-core 20.4.0

High-performance IFC/STEP parser for building data
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
430
431
// 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/.

use super::*;
use std::cell::Cell;

thread_local! {
    static COMMENT_AWARE_CALLS: Cell<usize> = const { Cell::new(0) };
}

pub(super) fn mark_comment_aware_call() {
    COMMENT_AWARE_CALLS.with(|calls| calls.set(calls.get() + 1));
}

fn comment_aware_calls() -> usize {
    COMMENT_AWARE_CALLS.with(Cell::get)
}

/// The #4720 regression was not incorrect output: it put comment detection in
/// every delimiter iteration of the overwhelmingly comment-free point-list
/// path. Keep the dispatch itself observable so reverting #4735 makes this
/// test fail even though both implementations return the same numbers.
#[test]
fn comment_free_lists_stay_out_of_the_comment_aware_loops() {
    COMMENT_AWARE_CALLS.with(|calls| calls.set(0));

    assert_eq!(parse_coordinates_direct(b"((1.,2.,3.))"), [1.0, 2.0, 3.0]);
    assert_eq!(
        parse_coordinates_direct_f64(b"((1.,2.,3.))"),
        [1.0, 2.0, 3.0]
    );
    assert_eq!(parse_indices_direct(b"((1,2,3))"), [0, 1, 2]);
    assert_eq!(comment_aware_calls(), 0);

    assert_eq!(
        parse_coordinates_direct(b"((1.,/* 9 */2.,3.))"),
        [1.0, 2.0, 3.0]
    );
    assert_eq!(
        parse_coordinates_direct_f64(b"((1.,/* 9 */2.,3.))"),
        [1.0, 2.0, 3.0]
    );
    assert_eq!(parse_indices_direct(b"((1,/* 9 */2,3))"), [0, 1, 2]);
    assert_eq!(comment_aware_calls(), 3);
}

#[test]
fn test_parse_coordinates_direct() {
    let bytes = b"((0.,0.,150.),(0.,40.,140.),(100.,0.,0.))";
    let coords = parse_coordinates_direct(bytes);

    assert_eq!(coords.len(), 9);
    assert!((coords[0] - 0.0).abs() < 0.001);
    assert!((coords[1] - 0.0).abs() < 0.001);
    assert!((coords[2] - 150.0).abs() < 0.001);
    assert!((coords[3] - 0.0).abs() < 0.001);
    assert!((coords[4] - 40.0).abs() < 0.001);
    assert!((coords[5] - 140.0).abs() < 0.001);
}

#[test]
fn test_parse_indices_direct() {
    let bytes = b"((1,2,3),(2,1,4),(5,6,7))";
    let indices = parse_indices_direct(bytes);

    assert_eq!(indices.len(), 9);
    // Should be 0-based (1-based converted)
    assert_eq!(indices[0], 0); // 1 -> 0
    assert_eq!(indices[1], 1); // 2 -> 1
    assert_eq!(indices[2], 2); // 3 -> 2
    assert_eq!(indices[3], 1); // 2 -> 1
    assert_eq!(indices[4], 0); // 1 -> 0
    assert_eq!(indices[5], 3); // 4 -> 3
}

#[test]
fn test_parse_indices_direct_rejects_out_of_range() {
    // 4294967297 = 2^32 + 1. Wrapping arithmetic would map it to 1 (→ 0
    // after the 1-based fixup), silently aliasing a valid-looking vertex.
    // Checked+saturate turns it into an obviously out-of-range sentinel
    // (u32::MAX - 1) the downstream bounds checks drop.
    let bytes = b"((4294967297,1,2))";
    let indices = parse_indices_direct(bytes);

    assert_eq!(indices.len(), 3);
    assert_eq!(
        indices[0],
        u32::MAX - 1,
        "overflowing index must saturate, not wrap to a valid vertex"
    );
    assert_eq!(indices[1], 0); // 1 -> 0
    assert_eq!(indices[2], 1); // 2 -> 1
}

#[test]
fn test_parse_indices_direct_boundary_values() {
    // 4294967295 = u32::MAX exactly: fits, no overflow, 1-based fixup applies.
    // 4294967296 = 2^32: first overflowing value, saturates to u32::MAX.
    // 4294967294 = u32::MAX - 1: fits.
    let bytes = b"((4294967295,4294967296,4294967294))";
    let indices = parse_indices_direct(bytes);
    assert_eq!(
        indices,
        vec![u32::MAX - 1, u32::MAX - 1, u32::MAX - 2],
        "boundary values must saturate deterministically"
    );
}

#[test]
fn test_parse_indices_direct_overflow_consumes_all_digits() {
    // After overflow is detected, remaining digits must still be consumed so
    // the parser resynchronizes on the next value instead of splitting the
    // huge number into several bogus indices.
    let bytes = b"((99999999999999999999999999,2))";
    let indices = parse_indices_direct(bytes);
    assert_eq!(indices, vec![u32::MAX - 1, 1]);
}

#[test]
fn test_parse_indices_direct_matches_old_wrapping_for_in_range_values() {
    // The checked/saturating parse must be byte-for-byte identical to the old
    // wrapping parse for every index that does NOT overflow u32 — i.e. all
    // valid inputs. Reference reimplementation of the pre-hardening logic:
    fn old_parse(bytes: &[u8]) -> Vec<u32> {
        let mut result = Vec::new();
        let mut pos = 0;
        let len = bytes.len();
        while pos < len {
            while pos < len && !bytes[pos].is_ascii_digit() {
                pos += 1;
            }
            if pos >= len {
                break;
            }
            let mut value: u32 = 0;
            while pos < len && bytes[pos].is_ascii_digit() {
                value = value
                    .wrapping_mul(10)
                    .wrapping_add((bytes[pos] - b'0') as u32);
                pos += 1;
            }
            result.push(value.saturating_sub(1));
        }
        result
    }

    let cases: &[&[u8]] = &[
        b"((1,2,3),(2,1,4),(5,6,7))",
        b"((0,1,4294967295))", // 0 (already 0 after saturating_sub) and u32::MAX
        b"((429496729,1000000000,999999999))",
        b"((10,200,3000),(40000,500000,6000000))",
    ];
    for bytes in cases {
        assert_eq!(
            parse_indices_direct(bytes),
            old_parse(bytes),
            "in-range parse changed for {:?}",
            std::str::from_utf8(bytes).unwrap()
        );
    }
}

#[test]
fn test_parse_scientific_notation() {
    let bytes = b"((1.5E-10,2.0e+5,-3.14))";
    let coords = parse_coordinates_direct(bytes);

    assert_eq!(coords.len(), 3);
    assert!((coords[0] - 1.5e-10).abs() < 1e-15);
    assert!((coords[1] - 2.0e5).abs() < 1.0);
    assert!((coords[2] - (-std::f32::consts::PI)).abs() < 0.01);
}

#[test]
fn test_parse_negative_numbers() {
    let bytes = b"((-1.0,-2.5,3.0))";
    let coords = parse_coordinates_direct(bytes);

    assert_eq!(coords.len(), 3);
    assert!((coords[0] - (-1.0)).abs() < 0.001);
    assert!((coords[1] - (-2.5)).abs() < 0.001);
    assert!((coords[2] - 3.0).abs() < 0.001);
}

#[test]
fn test_extract_coordinate_list() {
    let entity = b"#78=IFCCARTESIANPOINTLIST3D(((0.,0.,150.),(100.,0.,0.)));";
    let coords = extract_coordinate_list_from_entity(entity).unwrap();

    assert_eq!(coords.len(), 6);
    assert!((coords[0] - 0.0).abs() < 0.001);
    assert!((coords[2] - 150.0).abs() < 0.001);
    assert!((coords[3] - 100.0).abs() < 0.001);
}

/// The CoordList is attribute 0, found by depth: the old first-`((`-to-last-`))`
/// span read an IFC4X3 TagList's digits as coordinates and refused a list
/// written `( (`. Core review behind #4577 (finding 6).
#[test]
fn coordinate_list_is_attribute_zero_found_by_depth() {
    let read = extract_coordinate_list_from_entity;
    let tagged = b"#1=IFCCARTESIANPOINTLIST3D(((0.,1.,2.)),('P9'));";
    assert_eq!(read(tagged).unwrap(), [0.0, 1.0, 2.0]);
    let spaced = b"#1=IFCCARTESIANPOINTLIST3D( /* c) */\t( (0.,1.,2.) ),$);";
    assert_eq!(read(spaced).unwrap(), [0.0, 1.0, 2.0]);
    // A `(` in a head comment does not open the list (the rule
    // `nth_attribute_is_present` uses; a bare `memchr` landed inside it).
    let head = b"#1=IFCCARTESIANPOINTLIST3D /* (v2) */ (((0.,1.,2.)),$);";
    assert_eq!(read(head).unwrap(), [0.0, 1.0, 2.0]);
    assert!(read(b"#1=IFCCARTESIANPOINTLIST3D($,(('P1')));").is_none());
    assert!(read(b"#1=IFCCARTESIANPOINTLIST3D(((0.,1.,2.)").is_none());
}

/// `process_triangulated_faceset_direct` handed the coordinate entity's
/// WHOLE record to `parse_coordinates_direct`, which reads every number it
/// meets: the instance name `78` and the `3` in `IFCCARTESIANPOINTLIST3D`
/// came back as the first two coordinates, 11 floats for 3 points, and every
/// vertex was shifted by two. Reverting the fix fails the `positions`
/// assertion (it yields `[78.0, 3.0, 0.0, 0.0, 150.0, ...]`).
/// Found by the core review behind #4577 (finding 6).
#[test]
fn process_triangulated_faceset_direct_does_not_read_the_record_prefix_as_coordinates() {
    let faceset = b"#77=IFCTRIANGULATEDFACESET(#78,$,$,((1,2,3)),$);";
    let points: &[u8] = b"#78=IFCCARTESIANPOINTLIST3D(((0.,0.,150.),(0.,40.,140.),(100.,0.,0.)));";
    let mesh =
        process_triangulated_faceset_direct(faceset, |id| (id == 78).then(|| points.to_vec()))
            .expect("a well-formed faceset with a resolvable point list");

    assert_eq!(
        mesh.positions,
        [0.0, 0.0, 150.0, 0.0, 40.0, 140.0, 100.0, 0.0, 0.0]
    );
    assert_eq!(mesh.indices, [0, 1, 2]);
}

/// A point list whose attribute 0 is not a list has no coordinates to
/// read, so the faceset is refused rather than meshed from whatever numbers
/// the record prefix happens to carry.
#[test]
fn process_triangulated_faceset_direct_refuses_a_point_list_without_a_coordinate_span() {
    let faceset = b"#77=IFCTRIANGULATEDFACESET(#78,$,$,((1,2,3)),$);";
    let points: &[u8] = b"#78=IFCCARTESIANPOINTLIST3D($);";
    assert!(process_triangulated_faceset_direct(faceset, |_| Some(points.to_vec())).is_none());
}

#[test]
fn test_should_use_fast_path() {
    assert!(should_use_fast_path("IFCCARTESIANPOINTLIST3D"));
    assert!(should_use_fast_path("IFCTRIANGULATEDFACESET"));
    assert!(should_use_fast_path("IfcTriangulatedFaceSet"));
    assert!(!should_use_fast_path("IFCWALL"));
    assert!(!should_use_fast_path("IFCEXTRUDEDAREASOLID"));
}

/// `extract_entity_type_name`'s own contract, next to the function.
///
/// Its only production caller is `legacy_aware_ifc_type_from_record`, and the
/// cases below were exercised only from that caller's tests until now. If the
/// caller is ever deleted or rerouted, the contract keeps its coverage here.
///
/// The spaced forms are the reason this matters: STEP permits whitespace around
/// `=`, and buildingSMART's own `column-straight-rectangle-tessellation.ifc`
/// writes `#71= IFCCOLUMN(` on all 26 of its entity lines. Untrimmed, that
/// yielded `" IFCCOLUMN"` and matched no lookup.
#[test]
fn extract_entity_type_name_trims_and_rejects_empty() {
    for (record, expected) in [
        (&b"#12=IFCCOLUMN('g');"[..], Some("IFCCOLUMN")),
        (&b"#12= IFCCOLUMN('g');"[..], Some("IFCCOLUMN")),
        (&b"#12=\tIFCCOLUMN('g');"[..], Some("IFCCOLUMN")),
        // Nothing between `=` and `(`: an empty name is None, not Some("").
        (&b"#12=();"[..], None),
        (&b"#12= (  );"[..], None),
        // No `=` and no `(` are both None rather than a panic.
        (&b"IFCCOLUMN('g');"[..], None),
        (&b"#12=IFCCOLUMN"[..], None),
        (&b""[..], None),
    ] {
        assert_eq!(
            extract_entity_type_name(record),
            expected,
            "{:?}",
            std::str::from_utf8(record)
        );
    }
}

/// `extract_first_entity_ref` and `extract_entity_refs_from_list` are the two
/// `#<digits>` REFERENCE readers in this file (issue #3421, split from
/// #3395 which fixed only the definition side). Before this fix both
/// accumulated with `wrapping_mul`/`wrapping_add`, so a reference above
/// `u32::MAX` wrapped onto a real low-numbered entity instead of being
/// refused — the same defect #3395 fixed one hop earlier, in the reference
/// readers rather than the definition scanner.
///
/// `4294967297` is `% 2^32 == 1`: an unfixed reader binds it to a real `#1`
/// rather than merely erroring, which is the actual defect. `4294967295` is
/// `u32::MAX` exactly and must still resolve (the bound is inclusive).
#[test]
fn extract_first_entity_ref_refuses_above_u32_max_and_resolves_at_the_boundary() {
    // The defect value: would wrap to 1 without the bound.
    assert_eq!(
        extract_first_entity_ref(b"#77=IFCTRIANGULATEDFACESET(#4294967297,$);"),
        None,
        "a reference above u32::MAX must be refused, not aliased to #1"
    );
    // The inclusive boundary: u32::MAX itself must still resolve.
    assert_eq!(
        extract_first_entity_ref(b"#77=IFCTRIANGULATEDFACESET(#4294967295,$);"),
        Some(u32::MAX)
    );
    // An ordinary reference is unaffected.
    assert_eq!(
        extract_first_entity_ref(b"#77=IFCTRIANGULATEDFACESET(#78,$);"),
        Some(78)
    );
}

#[test]
fn extract_entity_refs_from_list_refuses_above_u32_max_and_resolves_at_the_boundary() {
    // The oversized id is dropped, not aliased to #1 — it must not appear in
    // the result at all, and the real #1 in the same list must not be
    // duplicated by the dropped one wrapping onto it.
    let ids = extract_entity_refs_from_list(b"(#1,#4294967297,#2)");
    assert_eq!(
        ids,
        vec![1, 2],
        "an out-of-range reference must be dropped, not wrapped onto #1"
    );

    // The inclusive boundary: u32::MAX itself must still resolve and appear.
    let ids = extract_entity_refs_from_list(b"(#1,#4294967295,#2)");
    assert_eq!(ids, vec![1, u32::MAX, 2]);
}

/// #4687: a comment inside a coordinate or index list is trivia. Its digits
/// used to become an extra coordinate or index, shifting every one after it.
#[test]
fn issue_4687_a_comment_in_a_coordinate_or_index_list_is_trivia() {
    let plain = b"#5=IFCCARTESIANPOINTLIST3D(((0.,0.,0.),(1.,0.,0.),(0.,1.,0.)));";
    let commented = b"#5=IFCCARTESIANPOINTLIST3D(((0.,0.,0.) /* rev 7 */,(1.,0.,0.),(0.,1.,0.)));";
    assert_eq!(extract_coordinate_list_from_entity(commented), extract_coordinate_list_from_entity(plain));
    assert_eq!(parse_coordinates_direct_f64(b"((0.,0.,0.)/*-.5*/,(1.,0.,0.))"), [0., 0., 0., 1., 0., 0.]);
    assert_eq!(parse_indices_direct(b"((1,2,3) /* face 2 */,(2,1,4))"), [0, 1, 2, 1, 0, 3]);
    // An unterminated comment ends the list rather than reading its contents.
    assert_eq!(parse_indices_direct(b"((1,2,3) /* 9 9"), [0, 1, 2]);
}

#[test]
fn extract_entity_refs_from_list_hash_with_no_digits_does_not_panic() {
    let ids = extract_entity_refs_from_list(b"(#,#2)");
    assert_eq!(ids, vec![2]);
}

/// #5266: a dropped comma corrupts one numeric literal into two, e.g.
/// `1.52.3` for what was meant to be `1.52,3`. `fast_float2::parse_partial`
/// parses the `1.52` prefix and reports it consumed, leaving `.3` free to be
/// misread as the START of the next coordinate -- every later value in the
/// list shifts by one position instead of the list being refused. Pins both
/// the non-comment-aware hot loops (`fast_parse.rs`) and their comment-aware
/// twins (`fast_parse_comments.rs`, reached here via the leading `/` in the
/// `_with_comment` cases, which routes through `may_contain_step_comment`).
#[test]
fn issue_5266_corrupted_literal_refuses_the_whole_list_not_split_into_two_coordinates() {
    // Unpatched: parse_coordinates_direct(b"((1.52.3,4.0,5.0))") == [1.52, 0.3, 4.0, 5.0].
    assert_eq!(parse_coordinates_direct(b"((1.52.3,4.0,5.0))"), Vec::<f32>::new());
    assert_eq!(parse_coordinates_direct_f64(b"((1.52.3,4.0,5.0))"), Vec::<f64>::new());

    // Same corrupted literal, forced through the comment-aware twins by a
    // `/* ... */` elsewhere in the same list so `may_contain_step_comment`
    // dispatches to them.
    assert_eq!(
        parse_coordinates_direct(b"((1.52.3,4.0,5.0) /* c */,(6.0,7.0,8.0))"),
        Vec::<f32>::new()
    );
    assert_eq!(
        parse_coordinates_direct_f64(b"((1.52.3,4.0,5.0) /* c */,(6.0,7.0,8.0))"),
        Vec::<f64>::new()
    );

    // Control: a legal comment glued directly onto a numeric literal, with no
    // delimiter before it, must still be accepted -- the fix must not
    // refuse every number a comment touches, only a corrupted one.
    assert_eq!(
        parse_coordinates_direct_f64(b"((1.5/* c */,2.0,3.0))"),
        [1.5, 2.0, 3.0]
    );
}

/// #5266 follow-up: a token the list walk cannot read as one whole STEP
/// literal refuses the list. Before, the walk skipped any byte that did not
/// start a number, so `nan` vanished and every later value shifted left.
#[test]
fn issue_5266_non_step_tokens_refuse_the_list_instead_of_vanishing() {
    for list in [&b"((nan,1.,2.))"[..], b"((inf,1.,2.))", b"((1.,2.,3.x))", b"(($,1.,2.))"] {
        let shown = String::from_utf8_lossy(list);
        assert_eq!(parse_coordinates_direct_f64(list), Vec::<f64>::new(), "{shown}");
        assert_eq!(parse_coordinates_direct(list), Vec::<f32>::new(), "{shown}");
    }
    // The comment-aware twin applies the same rule.
    assert_eq!(parse_coordinates_direct_f64(b"((nan,1.,2.) /* c */)"), Vec::<f64>::new());
    // A missing value, a trailing comma or a missing comma between points
    // would shorten or shift the list, so the tokenizer refuses them and so
    // does the walk.
    for list in [&b"((1.,,2.,3.),(4.,5.,6.))"[..], b"((1.,2.,))", b"((1.,2.,3.)(4.,5.,6.))", b"((,1.))", b"((1.,2.,3.)", b"((1.,2.,3.)))"] {
        assert_eq!(parse_coordinates_direct_f64(list), Vec::<f64>::new(), "{}", String::from_utf8_lossy(list));
    }
    // A refused CoordList is `None` from the entity reader, not an empty
    // success (Claude review on 4b0e3564d).
    assert_eq!(
        extract_coordinate_list_from_entity(b"#1=IFCCARTESIANPOINTLIST3D(((nan,1.,2.)),$);"),
        None
    );
    assert_eq!(
        extract_coordinate_list_from_entity(b"#1=IFCCARTESIANPOINTLIST3D(((0.,1.,2.)),$);"),
        Some(vec![0.0, 1.0, 2.0])
    );
    // A dropped comma with trivia in the gap is still a dropped comma.
    assert_eq!(parse_coordinates_direct_f64(b"((1.52 .3,4.,5.))"), Vec::<f64>::new());
    assert_eq!(parse_coordinates_direct_f64(b"((1.52/*c*/.3,4.,5.))"), Vec::<f64>::new());
    assert_eq!(
        parse_coordinates_direct_f64(b"((1. , 2. /* y */ ,3. ))"),
        [1.0, 2.0, 3.0]
    );
    // Legal forms keep reading, including signs, bare-dot and exponents.
    assert_eq!(
        parse_coordinates_direct_f64(b"((+1.5,.5,-1.E2),(0.,0.,3./* z */))"),
        [1.5, 0.5, -100.0, 0.0, 0.0, 3.0]
    );
}