Skip to main content

cadvm_core/
step.rs

1//! Lightweight, *textual* analysis of STEP/STP files.
2//!
3//! This is intentionally **not** a CAD parser: it never interprets geometry or
4//! B-Rep topology. It only does cheap string scanning to surface useful summary
5//! metadata (schema, entity counts, top entity types) for `log`/`diff`/`status`.
6//! Deep geometric understanding is deferred to a later Open CASCADE stage.
7
8use serde::{Deserialize, Serialize};
9
10/// A `(entity_type, count)` pair, used for the "top entity types" summary.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct EntityTypeCount {
13    pub entity_type: String,
14    pub count: u64,
15}
16
17/// Summary metadata extracted from a STEP file via text scanning only.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
19pub struct StepMetadata {
20    /// The schema named in `FILE_SCHEMA((...))`, if found.
21    pub file_schema: Option<String>,
22    /// Number of entity definitions (`#N = TYPE(...)`) in the DATA section.
23    pub entity_count: Option<u64>,
24    /// Line count of the HEADER section.
25    pub header_line_count: Option<u64>,
26    /// Line count of the DATA section.
27    pub data_line_count: Option<u64>,
28    /// The most frequent entity types (up to 20), descending by count.
29    pub top_entity_types: Vec<EntityTypeCount>,
30}
31
32/// How many entity types to keep in the "top types" list.
33const TOP_N: usize = 20;
34
35/// Extract [`StepMetadata`] from raw STEP file bytes.
36///
37/// Returns `None` only if the bytes are not valid UTF-8 (STEP files are ASCII
38/// text); otherwise it always returns a best-effort summary, never panics.
39pub fn extract(content: &[u8]) -> Option<StepMetadata> {
40    let text = std::str::from_utf8(content).ok()?;
41    Some(extract_str(text))
42}
43
44/// Like [`extract`] but operates on an already-decoded string.
45pub fn extract_str(text: &str) -> StepMetadata {
46    let file_schema = find_file_schema(text);
47    let (header_line_count, data_line_count) = section_line_counts(text);
48    let data = data_section(text).unwrap_or("");
49    let (entity_count, top_entity_types) = scan_entities(data);
50
51    StepMetadata {
52        file_schema,
53        entity_count: Some(entity_count),
54        header_line_count,
55        data_line_count,
56        top_entity_types,
57    }
58}
59
60/// Locate a case-insensitive keyword (e.g. `HEADER;`) and return its line index.
61fn find_keyword_line(text: &str, keyword: &str) -> Option<usize> {
62    let kw = keyword.to_ascii_uppercase();
63    text.lines()
64        .position(|line| line.trim().to_ascii_uppercase().starts_with(&kw))
65}
66
67/// Approximate HEADER and DATA section line counts (between their `SEC` markers
68/// and the following `ENDSEC;`).
69fn section_line_counts(text: &str) -> (Option<u64>, Option<u64>) {
70    let lines: Vec<&str> = text.lines().collect();
71    let count_section = |start_kw: &str| -> Option<u64> {
72        let start = find_keyword_line(text, start_kw)?;
73        let mut count = 0u64;
74        for line in lines.iter().skip(start + 1) {
75            if line.trim().to_ascii_uppercase().starts_with("ENDSEC") {
76                break;
77            }
78            count += 1;
79        }
80        Some(count)
81    };
82    (count_section("HEADER"), count_section("DATA"))
83}
84
85/// Extract the text of the DATA section (between `DATA;` and the next `ENDSEC;`).
86fn data_section(text: &str) -> Option<&str> {
87    let upper = text.to_ascii_uppercase();
88    let data_pos = upper.find("DATA;")?;
89    let after = data_pos + "DATA;".len();
90    let end_rel = upper[after..].find("ENDSEC").unwrap_or(upper.len() - after);
91    Some(&text[after..after + end_rel])
92}
93
94/// Find the schema string inside `FILE_SCHEMA(('...'))`.
95fn find_file_schema(text: &str) -> Option<String> {
96    let upper = text.to_ascii_uppercase();
97    let key = "FILE_SCHEMA";
98    let pos = upper.find(key)?;
99    // Take the slice after the keyword and pull out the first single-quoted token.
100    let rest = &text[pos + key.len()..];
101    let start = rest.find('\'')?;
102    let after = &rest[start + 1..];
103    let end = after.find('\'')?;
104    let schema = after[..end].trim();
105    if schema.is_empty() {
106        None
107    } else {
108        Some(schema.to_string())
109    }
110}
111
112/// Scan a DATA section for entity definitions of the form `#N = TYPE(`.
113///
114/// Returns the total count and the top-N most frequent entity types. Bare `#N`
115/// *references* inside other entities are ignored because they are not followed
116/// by `= TYPE(`.
117fn scan_entities(data: &str) -> (u64, Vec<EntityTypeCount>) {
118    use std::collections::HashMap;
119
120    let bytes = data.as_bytes();
121    let mut counts: HashMap<&str, u64> = HashMap::new();
122    let mut total: u64 = 0;
123    let mut i = 0usize;
124    let n = bytes.len();
125
126    while i < n {
127        if bytes[i] != b'#' {
128            i += 1;
129            continue;
130        }
131        // Parse the entity id digits after '#'.
132        let mut j = i + 1;
133        while j < n && bytes[j].is_ascii_digit() {
134            j += 1;
135        }
136        if j == i + 1 {
137            // No digits after '#'; not an id.
138            i += 1;
139            continue;
140        }
141        // Skip whitespace, then require '='.
142        let mut k = j;
143        while k < n && bytes[k].is_ascii_whitespace() {
144            k += 1;
145        }
146        if k >= n || bytes[k] != b'=' {
147            i = j;
148            continue;
149        }
150        k += 1;
151        // Skip whitespace before the type name.
152        while k < n && bytes[k].is_ascii_whitespace() {
153            k += 1;
154        }
155        // A complex-entity instance looks like `#N = ( ... )`; skip past '(' for
156        // the type-name scan but still count it.
157        if k < n && bytes[k] == b'(' {
158            k += 1;
159            while k < n && bytes[k].is_ascii_whitespace() {
160                k += 1;
161            }
162        }
163        // Read the identifier (uppercase letters, digits, underscores).
164        let type_start = k;
165        while k < n && (bytes[k].is_ascii_alphanumeric() || bytes[k] == b'_') {
166            k += 1;
167        }
168        if k > type_start {
169            let ty = &data[type_start..k];
170            // A definition is an identifier directly preceding '('.
171            let mut m = k;
172            while m < n && bytes[m].is_ascii_whitespace() {
173                m += 1;
174            }
175            if m < n && bytes[m] == b'(' {
176                total += 1;
177                *counts.entry(ty).or_insert(0) += 1;
178            }
179        }
180        i = k.max(j);
181    }
182
183    let mut top: Vec<EntityTypeCount> = counts
184        .into_iter()
185        .map(|(entity_type, count)| EntityTypeCount {
186            entity_type: entity_type.to_string(),
187            count,
188        })
189        .collect();
190    // Sort by count descending, then name ascending for stable output.
191    top.sort_by(|a, b| {
192        b.count
193            .cmp(&a.count)
194            .then_with(|| a.entity_type.cmp(&b.entity_type))
195    });
196    top.truncate(TOP_N);
197
198    (total, top)
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    const SAMPLE: &str = "\
206ISO-10303-21;
207HEADER;
208FILE_DESCRIPTION(('Example'),'2;1');
209FILE_NAME('cube.step','2026-06-12T00:00:00',('cadvm'),('cadvm'),'','','');
210FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));
211ENDSEC;
212DATA;
213#1 = CARTESIAN_POINT('', (0.0, 0.0, 0.0));
214#2 = DIRECTION('', (0.0, 0.0, 1.0));
215#3 = CARTESIAN_POINT('', (1.0, 0.0, 0.0));
216ENDSEC;
217END-ISO-10303-21;
218";
219
220    #[test]
221    fn extracts_schema_and_counts() {
222        let md = extract_str(SAMPLE);
223        assert_eq!(md.file_schema.as_deref(), Some("AUTOMOTIVE_DESIGN"));
224        assert_eq!(md.entity_count, Some(3));
225        let top = &md.top_entity_types;
226        assert_eq!(top[0].entity_type, "CARTESIAN_POINT");
227        assert_eq!(top[0].count, 2);
228        assert_eq!(md.data_line_count, Some(3));
229    }
230
231    #[test]
232    fn ignores_references_inside_entities() {
233        let data = "#10 = ADVANCED_FACE('',(#11,#12),#13,.T.);\n#11 = PLANE('',#14);\n";
234        let (total, top) = scan_entities(data);
235        assert_eq!(total, 2);
236        assert!(top.iter().any(|t| t.entity_type == "ADVANCED_FACE"));
237        assert!(top.iter().all(|t| t.entity_type != "PLANE" || t.count == 1));
238    }
239}