Skip to main content

ifc_step/
index.rs

1//! Offset index: what is in a file, without decoding it.
2//!
3//! # Why this exists
4//!
5//! Building a [`Model`] decodes every attribute of every record into an
6//! owned `Value`. On a 513 MB export that is 18 s and 2.3 GB. A consumer
7//! that only needs a type census, or the 200 walls out of nine million
8//! records, pays the full price for work it discards.
9//!
10//! This module answers those questions from byte offsets alone. Records are
11//! decoded one at a time, on request, from the borrowed source.
12//!
13//! # Trust
14//!
15//! The scanner finds record boundaries without running the full lexer, so it
16//! could in principle disagree with it. `tests/index_agreement.rs` asserts on
17//! every fixture in the repository that the index finds exactly the ids the
18//! eager parser finds, and that each lazily decoded entity equals its eager
19//! counterpart. That test is the reason to believe this module.
20
21use ifc_model::{Codec, Entity, EntityId, Model, Value};
22use openbim_step::ParseOptions;
23
24/// Advances past a STEP string literal, honouring the doubled-quote escape.
25///
26/// Returns the offset just past the closing quote. A string can contain `#`,
27/// `;`, and `(`/`)`, so a scanner that does not skip strings wholesale will
28/// split records in the middle of a name and silently lose data.
29/// The doubled-quote branch below is deliberate but, for boundary
30/// finding alone, provably redundant: quote runs in valid STEP are always
31/// even, so a scanner that treated an escape as close-then-reopen would
32/// re-synchronise before the terminating semicolon. Exhaustive search over
33/// short records found no input where the two disagree, so no test can
34/// kill a mutation of it. It stays because it makes this function correct
35/// in isolation rather than correct by a whole-file parity argument.
36fn skip_text(bytes: &[u8], mut i: usize) -> usize {
37    i += 1;
38    while i < bytes.len() {
39        if bytes[i] == b'\'' {
40            if bytes.get(i + 1) == Some(&b'\'') {
41                i += 2;
42                continue;
43            }
44            return i + 1;
45        }
46        i += 1;
47    }
48    i
49}
50
51/// Advances past a `/* ... */` comment.
52fn skip_comment(bytes: &[u8], mut i: usize) -> usize {
53    i += 2;
54    while i + 1 < bytes.len() {
55        if bytes[i] == b'*' && bytes[i + 1] == b'/' {
56            return i + 2;
57        }
58        i += 1;
59    }
60    bytes.len()
61}
62
63/// One scanned record: `#id=TYPE(...);`
64struct Scanned {
65    id: u64,
66    start: usize,
67    end: usize,
68    type_start: usize,
69    type_end: usize,
70}
71
72/// Walks DATA looking only for record starts and the semicolon that ends
73/// each one. Everything between is skipped as opaque bytes, except strings
74/// and comments, which must be stepped over so their contents are not
75/// mistaken for structure.
76fn scan_records(bytes: &[u8]) -> Vec<Scanned> {
77    let mut out = Vec::new();
78    let mut i = find_data_section(bytes);
79    while i < bytes.len() {
80        match bytes[i] {
81            b'\'' => {
82                i = skip_text(bytes, i);
83            }
84            b'/' if bytes.get(i + 1) == Some(&b'*') => {
85                i = skip_comment(bytes, i);
86            }
87            b'#' => {
88                let Some(rec) = scan_one(bytes, i) else {
89                    i += 1;
90                    continue;
91                };
92                i = rec.end;
93                out.push(rec);
94            }
95            _ => {
96                i += 1;
97            }
98        }
99    }
100    out
101}
102
103/// Reads one `#id=TYPE(...);` starting at `#`, or `None` if this `#` is a
104/// reference rather than a record head.
105fn scan_one(bytes: &[u8], start: usize) -> Option<Scanned> {
106    let mut i = start + 1;
107    let digits = i;
108    while i < bytes.len() && bytes[i].is_ascii_digit() {
109        i += 1;
110    }
111    if i == digits {
112        return None;
113    }
114    let id: u64 = std::str::from_utf8(&bytes[digits..i]).ok()?.parse().ok()?;
115    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
116        i += 1;
117    }
118    if bytes.get(i) != Some(&b'=') {
119        return None;
120    }
121    i += 1;
122    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
123        i += 1;
124    }
125    let type_start = i;
126    while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
127        i += 1;
128    }
129    let type_end = i;
130    while i < bytes.len() {
131        match bytes[i] {
132            b'\'' => {
133                i = skip_text(bytes, i);
134            }
135            b'/' if bytes.get(i + 1) == Some(&b'*') => {
136                i = skip_comment(bytes, i);
137            }
138            b';' => {
139                return Some(Scanned {
140                    id,
141                    start,
142                    end: i + 1,
143                    type_start,
144                    type_end,
145                })
146            }
147            _ => {
148                i += 1;
149            }
150        }
151    }
152    None
153}
154
155/// Offset just past `DATA;`, or 0 when the file has no DATA marker.
156fn find_data_section(bytes: &[u8]) -> usize {
157    let needle = b"DATA";
158    let mut i = 0;
159    while i + needle.len() <= bytes.len() {
160        if bytes[i..].starts_with(needle) {
161            let mut j = i + needle.len();
162            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
163                j += 1;
164            }
165            if bytes.get(j) == Some(&b';') {
166                return j + 1;
167            }
168        }
169        i += 1;
170    }
171    0
172}
173
174/// What is in a file, without what it says.
175///
176/// Borrows the source bytes and stores four parallel columns plus a table of
177/// distinct type names. At roughly 24 bytes per entity this is two orders of
178/// magnitude smaller than the decoded [`Model`].
179///
180/// Use [`Index::entity`] to decode one record, or [`Index::materialize_closure`]
181/// to build a real [`Model`] from a chosen subset.
182pub struct Index<'a> {
183    source: &'a [u8],
184    ids: Vec<u64>,
185    starts: Vec<u64>,
186    lens: Vec<u32>,
187    type_ids: Vec<u32>,
188    type_names: Vec<String>,
189}
190
191impl<'a> Index<'a> {
192    /// Scans `source` for record boundaries. Does not decode attributes.
193    #[must_use]
194    pub fn scan(source: &'a [u8]) -> Self {
195        let scanned = scan_records(source);
196        let mut ids = Vec::with_capacity(scanned.len());
197        let mut starts = Vec::with_capacity(scanned.len());
198        let mut lens = Vec::with_capacity(scanned.len());
199        let mut type_ids = Vec::with_capacity(scanned.len());
200        let mut type_names: Vec<String> = Vec::new();
201        let mut seen: std::collections::HashMap<&[u8], u32> = std::collections::HashMap::new();
202        for rec in scanned {
203            let name = &source[rec.type_start..rec.type_end];
204            let next = type_names.len() as u32;
205            let tid = *seen.entry(name).or_insert(next);
206            if tid == next {
207                type_names.push(String::from_utf8_lossy(name).to_ascii_uppercase());
208            }
209            ids.push(rec.id);
210            starts.push(rec.start as u64);
211            lens.push((rec.end - rec.start) as u32);
212            type_ids.push(tid);
213        }
214        Self {
215            source,
216            ids,
217            starts,
218            lens,
219            type_ids,
220            type_names,
221        }
222    }
223
224    /// Number of records found.
225    #[must_use]
226    pub fn len(&self) -> usize {
227        self.ids.len()
228    }
229
230    /// Whether the file has no data records.
231    #[must_use]
232    pub fn is_empty(&self) -> bool {
233        self.ids.is_empty()
234    }
235
236    /// Every id, in file order.
237    pub fn ids(&self) -> impl Iterator<Item = EntityId> + '_ {
238        self.ids.iter().copied().map(EntityId)
239    }
240
241    /// Upper-cased type name of `id`, without decoding it.
242    #[must_use]
243    pub fn type_of(&self, id: EntityId) -> Option<&str> {
244        let pos = self.position(id)?;
245        Some(&self.type_names[self.type_ids[pos] as usize])
246    }
247
248    /// How many records of each type. The census a viewer opens with.
249    #[must_use]
250    pub fn count_by_type(&self) -> std::collections::BTreeMap<&str, usize> {
251        let mut out = std::collections::BTreeMap::new();
252        for tid in &self.type_ids {
253            *out.entry(self.type_names[*tid as usize].as_str())
254                .or_insert(0) += 1;
255        }
256        out
257    }
258
259    /// Ids of every record whose type matches `name`, case-insensitively.
260    #[must_use]
261    pub fn ids_of_type(&self, name: &str) -> Vec<EntityId> {
262        let upper = name.to_ascii_uppercase();
263        let Some(tid) = self.type_names.iter().position(|n| *n == upper) else {
264            return Vec::new();
265        };
266        let tid = tid as u32;
267        self.type_ids
268            .iter()
269            .enumerate()
270            .filter(|(_, t)| **t == tid)
271            .map(|(i, _)| EntityId(self.ids[i]))
272            .collect()
273    }
274
275    fn position(&self, id: EntityId) -> Option<usize> {
276        self.ids.iter().position(|i| *i == id.0)
277    }
278
279    /// Decodes one record into an [`Entity`].
280    ///
281    /// The record is wrapped in a minimal synthetic file and handed to the
282    /// real parser, so a lazily decoded entity cannot drift from its eager
283    /// counterpart: there is only one decoder.
284    #[must_use]
285    pub fn entity(&self, id: EntityId) -> Option<Entity> {
286        let pos = self.position(id)?;
287        let start = self.starts[pos] as usize;
288        let end = start + self.lens[pos] as usize;
289        let model = self.decode_span(start, end)?;
290        model.get(id).cloned()
291    }
292
293    /// Wraps one or more raw records in a minimal STEP envelope and parses
294    /// them. The header is synthetic; only the DATA section is real.
295    fn decode_span(&self, start: usize, end: usize) -> Option<Model> {
296        let mut buf = Vec::with_capacity(end - start + 160);
297        buf.extend_from_slice(ENVELOPE_HEAD);
298        buf.extend_from_slice(&self.source[start..end]);
299        buf.extend_from_slice(ENVELOPE_TAIL);
300        crate::StepReader::new(ParseOptions::strict())
301            .read_bytes(&buf)
302            .ok()
303    }
304
305    /// Builds a [`Model`] from exactly `wanted`, and nothing else.
306    ///
307    /// # Dangling references
308    ///
309    /// The result is a real `Model` but not a self-contained one: an entity
310    /// that referenced `#7` still says `Ref(7)` even when `#7` was not
311    /// requested. Traversal will not resolve it. Use
312    /// [`Index::materialize_closure`] unless you specifically want the raw
313    /// subset and will handle missing targets yourself.
314    #[must_use]
315    pub fn materialize(&self, wanted: &[EntityId]) -> Model {
316        self.build(wanted)
317    }
318
319    /// Builds a [`Model`] containing `wanted` and everything they reach.
320    ///
321    /// Follows references transitively, so the result has no dangling
322    /// `Ref`. This is the constructor to reach for: it is what makes a
323    /// subset independently usable.
324    #[must_use]
325    pub fn materialize_closure(&self, wanted: &[EntityId]) -> Model {
326        let mut needed: std::collections::BTreeSet<u64> = wanted.iter().map(|i| i.0).collect();
327        let mut frontier: Vec<u64> = needed.iter().copied().collect();
328        while let Some(id) = frontier.pop() {
329            let Some(entity) = self.entity(EntityId(id)) else {
330                continue;
331            };
332            for value in &entity.attributes {
333                collect_refs(value, &mut needed, &mut frontier);
334            }
335        }
336        let ids: Vec<EntityId> = needed.into_iter().map(EntityId).collect();
337        self.build(&ids)
338    }
339
340    /// Concatenates the raw bytes of `wanted` and parses them in one pass.
341    fn build(&self, wanted: &[EntityId]) -> Model {
342        let mut buf = Vec::new();
343        buf.extend_from_slice(ENVELOPE_HEAD);
344        for id in wanted {
345            if let Some(pos) = self.position(*id) {
346                let start = self.starts[pos] as usize;
347                let end = start + self.lens[pos] as usize;
348                buf.extend_from_slice(&self.source[start..end]);
349                buf.push(b'\n');
350            }
351        }
352        buf.extend_from_slice(ENVELOPE_TAIL);
353        crate::StepReader::new(ParseOptions::strict())
354            .read_bytes(&buf)
355            .unwrap_or_default()
356    }
357}
358
359/// Minimal valid STEP prologue. The header is synthetic because the index
360/// decodes data records in isolation; callers that need real header fields
361/// read them from an eagerly parsed model.
362const ENVELOPE_HEAD: &[u8] = b"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((\'\'),\'2;1\');\nFILE_NAME(\'\',\'\',(\'\'),(\'\'),\'\',\'\',\'\');\nFILE_SCHEMA((\'IFC4\'));\nENDSEC;\nDATA;\n";
363const ENVELOPE_TAIL: &[u8] = b"ENDSEC;\nEND-ISO-10303-21;\n";
364
365/// Adds every `Ref` reachable from `value` to the work set.
366fn collect_refs(
367    value: &Value,
368    needed: &mut std::collections::BTreeSet<u64>,
369    frontier: &mut Vec<u64>,
370) {
371    match value {
372        Value::Ref(id) => {
373            if needed.insert(id.0) {
374                frontier.push(id.0);
375            }
376        }
377        Value::List(items) => {
378            for v in items {
379                collect_refs(v, needed, frontier);
380            }
381        }
382        Value::Typed { value, .. } => collect_refs(value, needed, frontier),
383        _ => {}
384    }
385}