Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(interop): read step files into documents with schema detection

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (Jun 8, 2026, 12:49 PM +0300) commit bbc2ed48 parent 04f7e8ca change-id sulpyukl
+181 -52
+1
Cargo.lock
··· 448 448 "slotmap", 449 449 "tempfile", 450 450 "thiserror 2.0.18", 451 + "tracing", 451 452 ] 452 453 453 454 [[package]]
+1
crates/bone-interop/Cargo.toml
··· 10 10 bone-kernel = { workspace = true } 11 11 bone-document = { workspace = true } 12 12 thiserror = { workspace = true } 13 + tracing = { workspace = true } 13 14 14 15 [dev-dependencies] 15 16 slotmap = { workspace = true }
+1 -1
crates/bone-interop/src/lib.rs
··· 1 1 pub mod step; 2 2 3 - pub use step::{ImportOutcome, StepError, body_of, read, write}; 3 + pub use step::{HeaderDefect, StepError, body_of, read, write};
+178 -51
crates/bone-interop/src/step.rs
··· 7 7 }; 8 8 use bone_kernel::{BrepError, BrepSolid}; 9 9 use bone_types::{ 10 - FaceRole, FeatureId, StepFileHeader, StepFileName, StepOrganization, StepOriginatingSystem, 11 - StepSchema, 10 + DocumentId, FeatureId, StepEntityKind, StepFileHeader, StepFileName, StepOrganization, 11 + StepOriginatingSystem, StepSchema, 12 12 }; 13 13 14 14 const PINNED_TIMESTAMP: &str = "1970-01-01T00:00:00"; 15 15 const ORIGINATING_SYSTEM: &str = concat!("Bone ", env!("CARGO_PKG_VERSION")); 16 + 17 + const SUPPORTED_READ: &[StepSchema] = &[StepSchema::Ap214]; 16 18 17 19 const fn schema_token(schema: StepSchema) -> &'static str { 18 20 match schema { ··· 23 25 } 24 26 } 25 27 28 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 29 + pub enum HeaderDefect { 30 + NoHeaderSection, 31 + NoFileSchema, 32 + UnreadableFileSchema, 33 + } 34 + 35 + impl HeaderDefect { 36 + #[must_use] 37 + const fn label(self) -> &'static str { 38 + match self { 39 + Self::NoHeaderSection => "no HEADER section", 40 + Self::NoFileSchema => "no FILE_SCHEMA entry", 41 + Self::UnreadableFileSchema => "an unreadable FILE_SCHEMA token", 42 + } 43 + } 44 + } 45 + 26 46 #[derive(Debug, thiserror::Error)] 27 47 pub enum StepError { 28 48 #[error("schema {0} is unsupported for export; only AP214 is emitted")] ··· 37 57 #[source] 38 58 source: ExtrudeError, 39 59 }, 60 + #[error("step geometry uses {kind}, which the reader does not yet bridge")] 61 + UnsupportedEntity { kind: StepEntityKind }, 62 + #[error("step file declares {found}; the reader supports {supported:?}")] 63 + SchemaMismatch { 64 + found: StepSchema, 65 + supported: &'static [StepSchema], 66 + }, 67 + #[error("step header carries {}", reason.label())] 68 + MalformedHeader { reason: HeaderDefect }, 69 + #[error("step file carries no importable solid")] 70 + IncompleteFile, 71 + #[error("step file carries {solids} solids; assemblies are not yet imported")] 72 + UnsupportedAssembly { solids: usize }, 40 73 #[error("io at {path}: {source}")] 41 74 Io { 42 75 path: PathBuf, ··· 53 86 }, 54 87 } 55 88 56 - pub enum ImportOutcome { 57 - Labeled(BrepSolid), 58 - Imported(BrepSolid), 59 - } 60 - 61 - impl ImportOutcome { 62 - #[must_use] 63 - pub fn solid(&self) -> &BrepSolid { 64 - match self { 65 - Self::Labeled(solid) | Self::Imported(solid) => solid, 66 - } 67 - } 68 - 69 - #[must_use] 70 - pub fn into_solid(self) -> BrepSolid { 71 - match self { 72 - Self::Labeled(solid) | Self::Imported(solid) => solid, 73 - } 74 - } 75 - 76 - #[must_use] 77 - pub fn is_labeled(&self) -> bool { 78 - matches!(self, Self::Labeled(_)) 79 - } 80 - } 81 - 82 89 pub fn write(document: &Document, path: &Path, schema: StepSchema) -> Result<(), StepError> { 83 90 if schema != StepSchema::Ap214 { 84 91 return Err(StepError::SchemaUnsupported(schema)); ··· 101 108 } 102 109 103 110 pub fn body_of(document: &Document) -> Result<BrepSolid, StepError> { 104 - let extrudes: Vec<FeatureId> = document 111 + let bodies: Vec<FeatureId> = document 105 112 .feature_tree() 106 113 .iter() 107 - .filter_map(|(feature, node)| matches!(node, FeatureNode::Extrude(_)).then_some(feature)) 114 + .filter_map(|(feature, node)| { 115 + matches!(node, FeatureNode::Extrude(_) | FeatureNode::ImportedBody(_)) 116 + .then_some(feature) 117 + }) 108 118 .collect(); 109 - let [feature] = extrudes.as_slice() else { 119 + let [feature] = bodies.as_slice() else { 110 120 return Err(StepError::BodyCount { 111 - count: extrudes.len(), 121 + count: bodies.len(), 112 122 }); 113 123 }; 114 124 let feature = *feature; 125 + if let Some(solid) = document.imported_body_of_feature(feature) { 126 + return Ok(solid.clone()); 127 + } 115 128 let extrude = document 116 129 .extrude_of_feature(feature) 117 130 .ok_or(StepError::DanglingExtrude { feature })?; ··· 124 137 .map_err(|source| StepError::Evaluation { feature, source }) 125 138 } 126 139 127 - pub fn read(path: &Path, feature: FeatureId) -> Result<ImportOutcome, StepError> { 140 + pub fn read(path: &Path) -> Result<Document, StepError> { 128 141 let text = read_file(path)?; 142 + classify_schema(&text)?; 129 143 let sidecar = read_sidecar(path)?; 130 - let solid = BrepSolid::from_step( 131 - &text, 132 - feature, 133 - sidecar.as_ref().map(|side| (side.solid(), side.reattach())), 134 - )?; 135 - let labeled = solid 136 - .iter_faces() 137 - .any(|face| !matches!(face.label().role, FaceRole::Imported { .. })); 138 - Ok(if labeled { 139 - ImportOutcome::Labeled(solid) 144 + let mut document = Document::new(DocumentId::default(), document_name(path)); 145 + document.import_body(|feature| { 146 + BrepSolid::from_step( 147 + &text, 148 + feature, 149 + sidecar.as_ref().map(|side| (side.solid(), side.reattach())), 150 + ) 151 + .map_err(read_geometry_error) 152 + })?; 153 + Ok(document) 154 + } 155 + 156 + fn read_geometry_error(error: BrepError) -> StepError { 157 + match error { 158 + BrepError::StepUnsupported { kind } => StepError::UnsupportedEntity { kind }, 159 + BrepError::StepSyntax | BrepError::StepNoData | BrepError::StepEmpty => { 160 + StepError::IncompleteFile 161 + } 162 + BrepError::StepMultipleSolids { count } => StepError::UnsupportedAssembly { solids: count }, 163 + other => StepError::Geometry(other), 164 + } 165 + } 166 + 167 + fn classify_schema(text: &str) -> Result<(), StepError> { 168 + let found: Vec<StepSchema> = file_schema_tokens(text)? 169 + .iter() 170 + .filter_map(|token| recognize_schema(token)) 171 + .collect(); 172 + if found.iter().any(|schema| SUPPORTED_READ.contains(schema)) { 173 + return Ok(()); 174 + } 175 + match found.iter().find(|schema| !SUPPORTED_READ.contains(schema)) { 176 + Some(&found) => Err(StepError::SchemaMismatch { 177 + found, 178 + supported: SUPPORTED_READ, 179 + }), 180 + None => Ok(()), 181 + } 182 + } 183 + 184 + fn file_schema_tokens(text: &str) -> Result<Vec<String>, StepError> { 185 + let header = header_section(text)?; 186 + let at = find_in_code_ci(header, "FILE_SCHEMA").ok_or(StepError::MalformedHeader { 187 + reason: HeaderDefect::NoFileSchema, 188 + })?; 189 + let statement = &header[at..]; 190 + let statement = statement 191 + .find(';') 192 + .map_or(statement, |end| &statement[..end]); 193 + let tokens: Vec<String> = quoted_segments(statement) 194 + .map(str::to_ascii_uppercase) 195 + .collect(); 196 + if tokens.is_empty() { 197 + return Err(StepError::MalformedHeader { 198 + reason: HeaderDefect::UnreadableFileSchema, 199 + }); 200 + } 201 + Ok(tokens) 202 + } 203 + 204 + fn header_section(text: &str) -> Result<&str, StepError> { 205 + let start = find_in_code_ci(text, "HEADER").ok_or(StepError::MalformedHeader { 206 + reason: HeaderDefect::NoHeaderSection, 207 + })?; 208 + let rest = &text[start..]; 209 + let end = find_in_code_ci(rest, "ENDSEC").unwrap_or(rest.len()); 210 + Ok(&rest[..end]) 211 + } 212 + 213 + fn quoted_segments(statement: &str) -> impl Iterator<Item = &str> { 214 + statement.split('\'').skip(1).step_by(2) 215 + } 216 + 217 + #[derive(Copy, Clone)] 218 + enum HeaderScan { 219 + Code, 220 + Quoted, 221 + Comment, 222 + } 223 + 224 + fn find_in_code_ci(haystack: &str, needle: &str) -> Option<usize> { 225 + let (hay, pat) = (haystack.as_bytes(), needle.as_bytes()); 226 + if pat.is_empty() || hay.len() < pat.len() { 227 + return None; 228 + } 229 + hay.iter() 230 + .scan((HeaderScan::Code, 0u8), |(state, prev), &byte| { 231 + let in_code = matches!(state, HeaderScan::Code); 232 + *state = match *state { 233 + HeaderScan::Code if byte == b'\'' => HeaderScan::Quoted, 234 + HeaderScan::Code if *prev == b'/' && byte == b'*' => HeaderScan::Comment, 235 + HeaderScan::Quoted if byte == b'\'' => HeaderScan::Code, 236 + HeaderScan::Comment if *prev == b'*' && byte == b'/' => HeaderScan::Code, 237 + other => other, 238 + }; 239 + *prev = byte; 240 + Some(in_code) 241 + }) 242 + .enumerate() 243 + .take(hay.len() - pat.len() + 1) 244 + .find(|&(start, in_code)| { 245 + in_code && hay[start..start + pat.len()].eq_ignore_ascii_case(pat) 246 + }) 247 + .map(|(start, _)| start) 248 + } 249 + 250 + fn recognize_schema(token: &str) -> Option<StepSchema> { 251 + if token.contains("AP242") || token.contains("MANAGED_MODEL_BASED") { 252 + Some(StepSchema::Ap242E2) 253 + } else if token.contains("AUTOMOTIVE_DESIGN") { 254 + Some(StepSchema::Ap214) 140 255 } else { 141 - ImportOutcome::Imported(solid) 142 - }) 256 + None 257 + } 258 + } 259 + 260 + fn document_name(path: &Path) -> String { 261 + path.file_stem().map_or_else( 262 + || "imported".to_owned(), 263 + |stem| stem.to_string_lossy().into_owned(), 264 + ) 143 265 } 144 266 145 267 fn envelope(header: &str, body: &str) -> String { ··· 216 338 fn read_sidecar(step: &Path) -> Result<Option<LabelSidecar>, StepError> { 217 339 let path = sidecar_path(step); 218 340 match std::fs::read_to_string(&path) { 219 - Ok(text) => LabelSidecar::from_ron(&text) 220 - .map(Some) 221 - .map_err(|source| StepError::Sidecar { 222 - path, 223 - source: Box::new(source), 224 - }), 341 + Ok(text) => Ok(match LabelSidecar::from_ron(&text) { 342 + Ok(sidecar) => Some(sidecar), 343 + Err(source) => { 344 + tracing::warn!( 345 + path = %path.display(), 346 + error = %source, 347 + "ignoring an unreadable step label sidecar" 348 + ); 349 + None 350 + } 351 + }), 225 352 Err(ref source) if source.kind() == io::ErrorKind::NotFound => Ok(None), 226 353 Err(source) => Err(StepError::Io { path, source }), 227 354 }