Skip to main content

brepkit_io/step/
reader.rs

1//! STEP AP203 file reader.
2//!
3//! Parses ISO 10303-21 (STEP Part 21) files and reconstructs B-Rep
4//! topology. Supports the entity types produced by our STEP writer:
5//! `MANIFOLD_SOLID_BREP`, `CLOSED_SHELL`, `ADVANCED_FACE`, `PLANE`,
6//! `EDGE_CURVE`, `LINE`, `CARTESIAN_POINT`, `DIRECTION`, etc.
7
8use std::collections::HashMap;
9
10use brepkit_math::vec::{Point3, Vec3};
11use brepkit_topology::Topology;
12use brepkit_topology::edge::{Edge, EdgeCurve};
13use brepkit_topology::face::{Face, FaceSurface};
14use brepkit_topology::shell::Shell;
15use brepkit_topology::solid::{Solid, SolidId};
16use brepkit_topology::vertex::Vertex;
17use brepkit_topology::wire::{OrientedEdge, Wire};
18
19use crate::IoError;
20
21/// Read a STEP file and reconstruct topology.
22///
23/// Returns the list of solid IDs created in the topology.
24///
25/// # Errors
26///
27/// Returns [`IoError`] if:
28/// - The file is not valid STEP Part 21
29/// - Required entities are missing or malformed
30/// - Entity references cannot be resolved
31pub fn read_step(input: &str, topo: &mut Topology) -> Result<Vec<SolidId>, IoError> {
32    let entities = parse_step_entities(input)?;
33    let mut builder = StepBuilder::new(topo, &entities);
34    builder.build_all_solids()
35}
36
37// ── Parsing ─────────────────────────────────────────────────────────
38
39/// A parsed STEP entity: `#id = TYPE(attrs)`.
40#[derive(Debug)]
41struct StepEntity {
42    entity_type: String,
43    attrs: String,
44}
45
46/// Parse all entity instances from the DATA section.
47fn parse_step_entities(input: &str) -> Result<HashMap<u64, StepEntity>, IoError> {
48    let mut entities = HashMap::new();
49
50    let data_start = input.find("DATA;").ok_or_else(|| IoError::ParseError {
51        reason: "no DATA section found".to_string(),
52    })?;
53    let data_end = input[data_start..]
54        .find("ENDSEC;")
55        .ok_or_else(|| IoError::ParseError {
56            reason: "no ENDSEC after DATA".to_string(),
57        })?;
58
59    let data_section = &input[data_start + 5..data_start + data_end];
60    let joined = data_section.replace(['\n', '\r'], " ");
61
62    for statement in joined.split(';') {
63        let stmt = statement.trim();
64        if stmt.is_empty() {
65            continue;
66        }
67
68        if let Some(eq_pos) = stmt.find('=') {
69            let id_part = stmt[..eq_pos].trim();
70            let rest = stmt[eq_pos + 1..].trim();
71
72            if let Some(id) = parse_entity_id(id_part)
73                && let Some(paren_pos) = rest.find('(')
74            {
75                let entity_type = rest[..paren_pos].trim().to_uppercase();
76                // Attrs = everything after the entity opening paren.
77                // E.g., for `TYPE('', (1.0, 2.0))`, attrs = `'', (1.0, 2.0))`
78                let attrs = rest[paren_pos + 1..].trim();
79
80                entities.insert(
81                    id,
82                    StepEntity {
83                        entity_type,
84                        attrs: attrs.to_string(),
85                    },
86                );
87            }
88        }
89    }
90
91    Ok(entities)
92}
93
94/// Parse `#123` into `123`.
95fn parse_entity_id(s: &str) -> Option<u64> {
96    let trimmed = s.trim();
97    trimmed.strip_prefix('#')?.parse().ok()
98}
99
100// ── Building ────────────────────────────────────────────────────────
101
102/// Reconstructs topology from parsed STEP entities.
103struct StepBuilder<'a> {
104    topo: &'a mut Topology,
105    entities: &'a HashMap<u64, StepEntity>,
106    vertex_cache: HashMap<u64, brepkit_topology::vertex::VertexId>,
107    edge_cache: HashMap<u64, brepkit_topology::edge::EdgeId>,
108}
109
110impl<'a> StepBuilder<'a> {
111    fn new(topo: &'a mut Topology, entities: &'a HashMap<u64, StepEntity>) -> Self {
112        Self {
113            topo,
114            entities,
115            vertex_cache: HashMap::new(),
116            edge_cache: HashMap::new(),
117        }
118    }
119
120    fn build_all_solids(&mut self) -> Result<Vec<SolidId>, IoError> {
121        let brep_ids: Vec<u64> = self
122            .entities
123            .iter()
124            .filter(|(_, e)| e.entity_type == "MANIFOLD_SOLID_BREP")
125            .map(|(&id, _)| id)
126            .collect();
127
128        let mut solid_ids = Vec::new();
129        for brep_id in brep_ids {
130            let solid_id = self.build_solid(brep_id)?;
131            solid_ids.push(solid_id);
132        }
133        Ok(solid_ids)
134    }
135
136    fn build_solid(&mut self, brep_id: u64) -> Result<SolidId, IoError> {
137        let attrs = self.get_entity(brep_id)?.attrs.clone();
138        let refs = parse_refs(&attrs);
139        // MANIFOLD_SOLID_BREP('name', #shell) — shell is the only #ref.
140        let shell_ref = refs.first().copied().ok_or_else(|| IoError::ParseError {
141            reason: format!("MANIFOLD_SOLID_BREP #{brep_id} missing shell reference"),
142        })?;
143
144        let shell_id = self.build_shell(shell_ref)?;
145        let solid_id = self.topo.add_solid(Solid::new(shell_id, Vec::new()));
146        Ok(solid_id)
147    }
148
149    fn build_shell(&mut self, shell_ref: u64) -> Result<brepkit_topology::shell::ShellId, IoError> {
150        let attrs = self.get_entity(shell_ref)?.attrs.clone();
151        let face_refs = parse_list_refs(&attrs);
152
153        let mut face_ids = Vec::new();
154        for face_ref in face_refs {
155            let face_id = self.build_face(face_ref)?;
156            face_ids.push(face_id);
157        }
158
159        let shell = Shell::new(face_ids).map_err(|e| IoError::ParseError {
160            reason: format!("failed to build shell from STEP: {e}"),
161        })?;
162        let shell_id = self.topo.add_shell(shell);
163        Ok(shell_id)
164    }
165
166    #[allow(clippy::too_many_lines)]
167    fn build_face(&mut self, face_ref: u64) -> Result<brepkit_topology::face::FaceId, IoError> {
168        let attrs = self.get_entity(face_ref)?.attrs.clone();
169        // Check for reversed face orientation (.F. flag at end of ADVANCED_FACE).
170        let orient_tail = attrs.trim_end_matches(')').trim();
171        let face_reversed = orient_tail.ends_with(".F.") || orient_tail.ends_with(".FALSE.");
172        let all_refs = parse_refs(&attrs);
173        let list_refs = parse_list_refs(&attrs);
174
175        // Surface ref is the last #ref that's not in the bounds list.
176        let list_set: std::collections::HashSet<u64> = list_refs.iter().copied().collect();
177        let surface_ref = all_refs
178            .iter()
179            .rev()
180            .find(|r| !list_set.contains(r))
181            .copied()
182            .ok_or_else(|| IoError::ParseError {
183                reason: format!("ADVANCED_FACE #{face_ref} missing surface reference"),
184            })?;
185
186        let surface = self.build_surface(surface_ref)?;
187
188        let mut outer_wire = None;
189        let mut inner_wires = Vec::new();
190
191        for &bound_ref in &list_refs {
192            let bound_entity = self.get_entity(bound_ref)?;
193            let is_outer = bound_entity.entity_type == "FACE_OUTER_BOUND";
194            let bound_attrs = bound_entity.attrs.clone();
195            let bound_refs = parse_refs(&bound_attrs);
196
197            if let Some(&loop_ref) = bound_refs.first() {
198                let wire_id = self.build_edge_loop(loop_ref)?;
199                if is_outer && outer_wire.is_none() {
200                    outer_wire = Some(wire_id);
201                } else {
202                    inner_wires.push(wire_id);
203                }
204            }
205        }
206
207        // If no FACE_OUTER_BOUND, use the first bound as outer.
208        let outer = outer_wire.or_else(|| {
209            if inner_wires.is_empty() {
210                None
211            } else {
212                Some(inner_wires.remove(0))
213            }
214        });
215
216        let outer = outer.ok_or_else(|| IoError::ParseError {
217            reason: format!("ADVANCED_FACE #{face_ref} has no bounds"),
218        })?;
219
220        let face_id = if face_reversed {
221            self.topo
222                .add_face(Face::new_reversed(outer, inner_wires, surface))
223        } else {
224            self.topo.add_face(Face::new(outer, inner_wires, surface))
225        };
226        Ok(face_id)
227    }
228
229    fn build_surface(&self, surface_ref: u64) -> Result<FaceSurface, IoError> {
230        let entity = self.get_entity(surface_ref)?;
231        let entity_type = entity.entity_type.clone();
232        let attrs = entity.attrs.clone();
233
234        match entity_type.as_str() {
235            "PLANE" => {
236                let refs = parse_refs(&attrs);
237                let axis_ref = refs.first().copied().ok_or_else(|| IoError::ParseError {
238                    reason: format!("PLANE #{surface_ref} missing axis reference"),
239                })?;
240                let (origin, normal, _ref_dir) = self.build_axis2_placement(axis_ref)?;
241                let d = normal.dot(Vec3::new(origin.x(), origin.y(), origin.z()));
242                Ok(FaceSurface::Plane { normal, d })
243            }
244            "CYLINDRICAL_SURFACE" => {
245                let refs = parse_refs(&attrs);
246                let floats = parse_floats(&attrs);
247                let axis_ref = refs.first().copied().ok_or_else(|| IoError::ParseError {
248                    reason: format!("CYLINDRICAL_SURFACE #{surface_ref} missing axis"),
249                })?;
250                let radius = floats.first().copied().ok_or_else(|| IoError::ParseError {
251                    reason: format!("CYLINDRICAL_SURFACE #{surface_ref} missing radius"),
252                })?;
253                let (origin, axis, _ref_dir) = self.build_axis2_placement(axis_ref)?;
254                let cyl = brepkit_math::surfaces::CylindricalSurface::new(origin, axis, radius)
255                    .map_err(|e| IoError::ParseError {
256                        reason: format!("CYLINDRICAL_SURFACE #{surface_ref}: {e}"),
257                    })?;
258                Ok(FaceSurface::Cylinder(cyl))
259            }
260            "CONICAL_SURFACE" => {
261                let refs = parse_refs(&attrs);
262                let floats = parse_floats(&attrs);
263                let axis_ref = refs.first().copied().ok_or_else(|| IoError::ParseError {
264                    reason: format!("CONICAL_SURFACE #{surface_ref} missing axis"),
265                })?;
266                // STEP: CONICAL_SURFACE('', #axis, base_radius, half_angle)
267                // half_angle is in radians in STEP AP203.
268                let half_angle = floats.last().copied().ok_or_else(|| IoError::ParseError {
269                    reason: format!("CONICAL_SURFACE #{surface_ref} missing half_angle"),
270                })?;
271                let (apex, axis, _ref_dir) = self.build_axis2_placement(axis_ref)?;
272                let cone = brepkit_math::surfaces::ConicalSurface::new(apex, axis, half_angle)
273                    .map_err(|e| IoError::ParseError {
274                        reason: format!("CONICAL_SURFACE #{surface_ref}: {e}"),
275                    })?;
276                Ok(FaceSurface::Cone(cone))
277            }
278            "SPHERICAL_SURFACE" => {
279                let refs = parse_refs(&attrs);
280                let floats = parse_floats(&attrs);
281                let axis_ref = refs.first().copied().ok_or_else(|| IoError::ParseError {
282                    reason: format!("SPHERICAL_SURFACE #{surface_ref} missing axis"),
283                })?;
284                let radius = floats.first().copied().ok_or_else(|| IoError::ParseError {
285                    reason: format!("SPHERICAL_SURFACE #{surface_ref} missing radius"),
286                })?;
287                let (center, _axis, _ref_dir) = self.build_axis2_placement(axis_ref)?;
288                let sphere = brepkit_math::surfaces::SphericalSurface::new(center, radius)
289                    .map_err(|e| IoError::ParseError {
290                        reason: format!("SPHERICAL_SURFACE #{surface_ref}: {e}"),
291                    })?;
292                Ok(FaceSurface::Sphere(sphere))
293            }
294            "TOROIDAL_SURFACE" => {
295                let refs = parse_refs(&attrs);
296                let floats = parse_floats(&attrs);
297                let axis_ref = refs.first().copied().ok_or_else(|| IoError::ParseError {
298                    reason: format!("TOROIDAL_SURFACE #{surface_ref} missing axis"),
299                })?;
300                let major_r = floats.first().copied().ok_or_else(|| IoError::ParseError {
301                    reason: format!("TOROIDAL_SURFACE #{surface_ref} missing major_radius"),
302                })?;
303                let minor_r = floats.get(1).copied().ok_or_else(|| IoError::ParseError {
304                    reason: format!("TOROIDAL_SURFACE #{surface_ref} missing minor_radius"),
305                })?;
306                let (center, axis, ref_dir) = self.build_axis2_placement(axis_ref)?;
307                let torus = brepkit_math::surfaces::ToroidalSurface::with_axis_and_ref_dir(
308                    center, major_r, minor_r, axis, ref_dir,
309                )
310                .map_err(|e| IoError::ParseError {
311                    reason: format!("TOROIDAL_SURFACE #{surface_ref}: {e}"),
312                })?;
313                Ok(FaceSurface::Torus(torus))
314            }
315            "B_SPLINE_SURFACE_WITH_KNOTS" | "BOUNDED_SURFACE" | "B_SPLINE_SURFACE" => {
316                let is_rational = attrs.contains("RATIONAL");
317                self.build_bspline_surface(surface_ref, &attrs, is_rational)
318            }
319            _ if entity_type.is_empty() || attrs.contains("B_SPLINE_SURFACE_WITH_KNOTS") => {
320                let is_rational = attrs.contains("RATIONAL");
321                let bspline_attrs = find_composite_bspline_attrs(&attrs, "B_SPLINE_SURFACE")
322                    .ok_or_else(|| IoError::UnsupportedEntity {
323                        entity: format!("composite surface #{surface_ref}"),
324                    })?;
325                self.build_bspline_surface(surface_ref, bspline_attrs, is_rational)
326            }
327            _ => Err(IoError::UnsupportedEntity {
328                entity: entity_type,
329            }),
330        }
331    }
332
333    fn build_edge_loop(
334        &mut self,
335        loop_ref: u64,
336    ) -> Result<brepkit_topology::wire::WireId, IoError> {
337        let attrs = self.get_entity(loop_ref)?.attrs.clone();
338        let oe_refs = parse_list_refs(&attrs);
339
340        let mut oriented_edges = Vec::new();
341        for oe_ref in oe_refs {
342            let oe = self.build_oriented_edge(oe_ref)?;
343            oriented_edges.push(oe);
344        }
345
346        let wire = Wire::new(oriented_edges, true).map_err(|e| IoError::ParseError {
347            reason: format!("failed to create wire from edge loop #{loop_ref}: {e}"),
348        })?;
349        let wire_id = self.topo.add_wire(wire);
350        Ok(wire_id)
351    }
352
353    fn build_oriented_edge(&mut self, oe_ref: u64) -> Result<OrientedEdge, IoError> {
354        let attrs = self.get_entity(oe_ref)?.attrs.clone();
355        let refs = parse_refs(&attrs);
356        let forward = attrs.contains(".T.");
357
358        let edge_curve_ref = refs.last().copied().ok_or_else(|| IoError::ParseError {
359            reason: format!("ORIENTED_EDGE #{oe_ref} missing edge curve reference"),
360        })?;
361
362        let edge_id = self.build_edge_curve(edge_curve_ref)?;
363        Ok(OrientedEdge::new(edge_id, forward))
364    }
365
366    fn build_edge_curve(&mut self, ec_ref: u64) -> Result<brepkit_topology::edge::EdgeId, IoError> {
367        if let Some(&cached) = self.edge_cache.get(&ec_ref) {
368            return Ok(cached);
369        }
370
371        let attrs = self.get_entity(ec_ref)?.attrs.clone();
372        let refs = parse_refs(&attrs);
373        if refs.len() < 3 {
374            return Err(IoError::ParseError {
375                reason: format!("EDGE_CURVE #{ec_ref} needs at least 3 references"),
376            });
377        }
378
379        let start_vp = self.build_vertex_point(refs[0])?;
380        let end_vp = self.build_vertex_point(refs[1])?;
381
382        let curve = self.build_curve_geometry(refs[2])?;
383
384        let edge_id = self.topo.add_edge(Edge::new(start_vp, end_vp, curve));
385
386        self.edge_cache.insert(ec_ref, edge_id);
387        Ok(edge_id)
388    }
389
390    /// Build the curve geometry for an edge from a curve entity reference.
391    ///
392    /// Dispatches on the entity type: LINE, CIRCLE, ELLIPSE,
393    /// `B_SPLINE_CURVE_WITH_KNOTS`.
394    fn build_curve_geometry(&self, curve_ref: u64) -> Result<EdgeCurve, IoError> {
395        let entity = self.get_entity(curve_ref)?;
396        let entity_type = entity.entity_type.clone();
397        let attrs = entity.attrs.clone();
398
399        match entity_type.as_str() {
400            "LINE" => Ok(EdgeCurve::Line),
401            "CIRCLE" => {
402                let refs = parse_refs(&attrs);
403                let floats = parse_floats(&attrs);
404                let axis_ref = refs.first().copied().ok_or_else(|| IoError::ParseError {
405                    reason: format!("CIRCLE #{curve_ref} missing axis reference"),
406                })?;
407                let radius = floats.first().copied().ok_or_else(|| IoError::ParseError {
408                    reason: format!("CIRCLE #{curve_ref} missing radius"),
409                })?;
410                let (center, normal, _u_axis) = self.build_axis2_placement(axis_ref)?;
411                let circle =
412                    brepkit_math::curves::Circle3D::new(center, normal, radius).map_err(|e| {
413                        IoError::ParseError {
414                            reason: format!("CIRCLE #{curve_ref}: {e}"),
415                        }
416                    })?;
417                Ok(EdgeCurve::Circle(circle))
418            }
419            "ELLIPSE" => {
420                let refs = parse_refs(&attrs);
421                let floats = parse_floats(&attrs);
422                let axis_ref = refs.first().copied().ok_or_else(|| IoError::ParseError {
423                    reason: format!("ELLIPSE #{curve_ref} missing axis reference"),
424                })?;
425                if floats.len() < 2 {
426                    return Err(IoError::ParseError {
427                        reason: format!("ELLIPSE #{curve_ref} needs semi_major and semi_minor"),
428                    });
429                }
430                let (center, normal, _u_axis) = self.build_axis2_placement(axis_ref)?;
431                let ellipse =
432                    brepkit_math::curves::Ellipse3D::new(center, normal, floats[0], floats[1])
433                        .map_err(|e| IoError::ParseError {
434                            reason: format!("ELLIPSE #{curve_ref}: {e}"),
435                        })?;
436                Ok(EdgeCurve::Ellipse(ellipse))
437            }
438            "B_SPLINE_CURVE_WITH_KNOTS" => self.build_bspline_curve(curve_ref, &attrs, false),
439            _ if entity_type.is_empty() || attrs.contains("B_SPLINE_CURVE_WITH_KNOTS") => {
440                let is_rational = attrs.contains("RATIONAL");
441                let bspline_attrs = find_composite_bspline_attrs(&attrs, "B_SPLINE_CURVE")
442                    .ok_or_else(|| IoError::UnsupportedEntity {
443                        entity: format!("composite curve #{curve_ref}"),
444                    })?;
445                self.build_bspline_curve(curve_ref, bspline_attrs, is_rational)
446            }
447            _ => Err(IoError::UnsupportedEntity {
448                entity: format!("{entity_type} (curve #{curve_ref})"),
449            }),
450        }
451    }
452
453    /// Build a B-spline curve from parsed attributes.
454    /// If `is_rational` is true, attempts to extract weights from a
455    /// RATIONAL_B_SPLINE_CURVE section in the attrs.
456    fn build_bspline_curve(
457        &self,
458        curve_ref: u64,
459        attrs: &str,
460        is_rational: bool,
461    ) -> Result<EdgeCurve, IoError> {
462        let parsed = parse_bspline_curve_attrs(attrs).ok_or_else(|| IoError::ParseError {
463            reason: format!("B_SPLINE_CURVE #{curve_ref} could not parse attributes"),
464        })?;
465        let (degree, cp_refs, mults, knot_vals) = parsed;
466
467        let mut control_points = Vec::with_capacity(cp_refs.len());
468        for &cp_ref in &cp_refs {
469            control_points.push(self.build_cartesian_point(cp_ref)?);
470        }
471
472        let knots = expand_knots(&mults, &knot_vals);
473
474        // Extract weights from RATIONAL_B_SPLINE section if present.
475        let weights = if is_rational {
476            extract_rational_weights(attrs, control_points.len())
477        } else {
478            vec![1.0; control_points.len()]
479        };
480
481        let nurbs = brepkit_math::nurbs::NurbsCurve::new(degree, knots, control_points, weights)
482            .map_err(|e| IoError::ParseError {
483                reason: format!("B_SPLINE_CURVE #{curve_ref}: {e}"),
484            })?;
485        Ok(EdgeCurve::NurbsCurve(nurbs))
486    }
487
488    /// Build a B-spline surface from parsed attributes.
489    fn build_bspline_surface(
490        &self,
491        surface_ref: u64,
492        attrs: &str,
493        is_rational: bool,
494    ) -> Result<FaceSurface, IoError> {
495        let parsed = parse_bspline_surface_attrs(attrs).ok_or_else(|| IoError::ParseError {
496            reason: format!("B_SPLINE_SURFACE #{surface_ref} could not parse attributes"),
497        })?;
498        let (degree_u, degree_v, cp_grid_refs, u_mults, v_mults, u_knots, v_knots) = parsed;
499
500        let mut cp_grid: Vec<Vec<Point3>> = Vec::new();
501        for row_refs in &cp_grid_refs {
502            let mut row: Vec<Point3> = Vec::new();
503            for &cp_ref in row_refs {
504                row.push(self.build_cartesian_point(cp_ref)?);
505            }
506            cp_grid.push(row);
507        }
508
509        let knots_u = expand_knots(&u_mults, &u_knots);
510        let knots_v = expand_knots(&v_mults, &v_knots);
511
512        let n_rows = cp_grid.len();
513        let n_cols = cp_grid.first().map_or(0, Vec::len);
514
515        let weights = if is_rational {
516            extract_rational_weight_grid(attrs, n_rows, n_cols)
517        } else {
518            vec![vec![1.0; n_cols]; n_rows]
519        };
520
521        let nurbs = brepkit_math::nurbs::NurbsSurface::new(
522            degree_u, degree_v, knots_u, knots_v, cp_grid, weights,
523        )
524        .map_err(|e| IoError::ParseError {
525            reason: format!("B_SPLINE_SURFACE #{surface_ref}: {e}"),
526        })?;
527        Ok(FaceSurface::Nurbs(nurbs))
528    }
529
530    fn build_vertex_point(
531        &mut self,
532        vp_ref: u64,
533    ) -> Result<brepkit_topology::vertex::VertexId, IoError> {
534        if let Some(&cached) = self.vertex_cache.get(&vp_ref) {
535            return Ok(cached);
536        }
537
538        let attrs = self.get_entity(vp_ref)?.attrs.clone();
539        let refs = parse_refs(&attrs);
540        let cp_ref = refs.first().copied().ok_or_else(|| IoError::ParseError {
541            reason: format!("VERTEX_POINT #{vp_ref} missing point reference"),
542        })?;
543
544        let point = self.build_cartesian_point(cp_ref)?;
545        let vid = self.topo.add_vertex(Vertex::new(point, 1e-7));
546
547        self.vertex_cache.insert(vp_ref, vid);
548        Ok(vid)
549    }
550
551    fn build_cartesian_point(&self, cp_ref: u64) -> Result<Point3, IoError> {
552        let attrs = &self.get_entity(cp_ref)?.attrs;
553        let coords = parse_floats(attrs);
554        if coords.len() < 3 {
555            return Err(IoError::ParseError {
556                reason: format!(
557                    "CARTESIAN_POINT #{cp_ref} needs 3 coordinates, got {}",
558                    coords.len()
559                ),
560            });
561        }
562        Ok(Point3::new(coords[0], coords[1], coords[2]))
563    }
564
565    fn build_direction(&self, dir_ref: u64) -> Result<Vec3, IoError> {
566        let attrs = &self.get_entity(dir_ref)?.attrs;
567        let coords = parse_floats(attrs);
568        if coords.len() < 3 {
569            return Err(IoError::ParseError {
570                reason: format!(
571                    "DIRECTION #{dir_ref} needs 3 components, got {}",
572                    coords.len()
573                ),
574            });
575        }
576        Ok(Vec3::new(coords[0], coords[1], coords[2]))
577    }
578
579    fn build_axis2_placement(&self, axis_ref: u64) -> Result<(Point3, Vec3, Vec3), IoError> {
580        let attrs = self.get_entity(axis_ref)?.attrs.clone();
581        let refs = parse_refs(&attrs);
582        if refs.len() < 3 {
583            return Err(IoError::ParseError {
584                reason: format!("AXIS2_PLACEMENT_3D #{axis_ref} needs 3 sub-references"),
585            });
586        }
587        let origin = self.build_cartesian_point(refs[0])?;
588        let axis = self.build_direction(refs[1])?;
589        let ref_dir = self.build_direction(refs[2])?;
590        Ok((origin, axis, ref_dir))
591    }
592
593    fn get_entity(&self, id: u64) -> Result<&StepEntity, IoError> {
594        self.entities.get(&id).ok_or_else(|| IoError::ParseError {
595            reason: format!("entity #{id} not found"),
596        })
597    }
598}
599
600// ── Attribute parsing helpers ───────────────────────────────────────
601
602/// Extract all `#NNN` references from an attribute string.
603fn parse_refs(attrs: &str) -> Vec<u64> {
604    let mut refs = Vec::new();
605    let mut i = 0;
606    let bytes = attrs.as_bytes();
607    while i < bytes.len() {
608        if bytes[i] == b'#' {
609            i += 1;
610            let start = i;
611            while i < bytes.len() && bytes[i].is_ascii_digit() {
612                i += 1;
613            }
614            if i > start
615                && let Ok(num) = attrs[start..i].parse::<u64>()
616            {
617                refs.push(num);
618            }
619        } else {
620            i += 1;
621        }
622    }
623    refs
624}
625
626/// Extract `#NNN` references from the first parenthesized list in attrs.
627fn parse_list_refs(attrs: &str) -> Vec<u64> {
628    if let Some(start) = attrs.find('(')
629        && let Some(end) = attrs[start..].find(')')
630    {
631        let inner = &attrs[start + 1..start + end];
632        return parse_refs(inner);
633    }
634    Vec::new()
635}
636
637/// Extract floating-point numbers from an attribute string.
638///
639/// Handles both nested `(1.0, 2.0)` and flat `'', #ref, 1.5E+00` formats.
640fn parse_floats(attrs: &str) -> Vec<f64> {
641    let mut result = Vec::new();
642    // Try nested parentheses first.
643    if let Some(start) = attrs.find('(')
644        && let Some(end) = attrs[start..].find(')')
645    {
646        let inner = &attrs[start + 1..start + end];
647        for part in inner.split(',') {
648            let trimmed = part.trim();
649            if let Ok(v) = trimmed.parse::<f64>() {
650                result.push(v);
651            }
652        }
653    }
654    // If no nested parens found, parse top-level comma-separated tokens.
655    if result.is_empty() {
656        for part in attrs.split(',') {
657            let trimmed = part.trim().trim_matches('\'').trim_end_matches(')');
658            if trimmed.starts_with('#') || trimmed.starts_with('.') || trimmed.is_empty() {
659                continue;
660            }
661            if let Ok(v) = trimmed.parse::<f64>() {
662                result.push(v);
663            }
664        }
665    }
666    result
667}
668
669/// Find the B-spline attribute substring within a composite STEP entity.
670///
671/// Searches for `"{base_name}_WITH_KNOTS"` first, then falls back to `base_name`.
672/// Returns the portion of `attrs` after the matched marker.
673fn find_composite_bspline_attrs<'a>(attrs: &'a str, base_name: &str) -> Option<&'a str> {
674    let with_knots = format!("{base_name}_WITH_KNOTS");
675    if let Some(pos) = attrs.find(&with_knots) {
676        return Some(&attrs[pos + with_knots.len()..]);
677    }
678    // Anchor on base_name followed by '(' to avoid matching inside
679    // "RATIONAL_B_SPLINE_CURVE" when searching for "B_SPLINE_CURVE".
680    let anchored = format!("{base_name}(");
681    if let Some(pos) = attrs.find(&anchored) {
682        return Some(&attrs[pos + base_name.len()..]);
683    }
684    None
685}
686
687/// Parse integers from a parenthesized list like `(4, 4)`.
688fn parse_ints_in_parens(s: &str) -> Vec<u32> {
689    let mut result = Vec::new();
690    for part in s.split(',') {
691        let trimmed = part.trim().trim_matches('(').trim_matches(')').trim();
692        if let Ok(v) = trimmed.parse::<u32>() {
693            result.push(v);
694        }
695    }
696    result
697}
698
699/// Extract weights from a RATIONAL_B_SPLINE section in composite entity attrs.
700///
701/// Looks for `RATIONAL_B_SPLINE_CURVE((...weights...))` or
702/// `RATIONAL_B_SPLINE_SURFACE((...weights...))` and parses the weight list.
703/// Falls back to uniform weights if parsing fails.
704fn extract_rational_weights(attrs: &str, expected_count: usize) -> Vec<f64> {
705    let marker = if attrs.contains("RATIONAL_B_SPLINE_SURFACE") {
706        "RATIONAL_B_SPLINE_SURFACE"
707    } else {
708        "RATIONAL_B_SPLINE_CURVE"
709    };
710
711    if let Some(pos) = attrs.find(marker) {
712        let after = &attrs[pos + marker.len()..];
713        if let Some(paren_start) = after.find('(') {
714            let rest = &after[paren_start + 1..];
715            let weights = parse_weight_list(rest);
716            if weights.len() >= expected_count {
717                return weights[..expected_count].to_vec();
718            }
719            // Partial parse (fewer than expected): fall back to uniform
720            // weights rather than propagating a dimension-mismatch error.
721        }
722    }
723
724    vec![1.0; expected_count]
725}
726
727/// Parse a (possibly nested) list of weights from RATIONAL_B_SPLINE attrs.
728/// Handles both flat `(w1, w2, w3)` and nested `((w1, w2), (w3, w4))` forms,
729/// as well as no-paren format `w1, w2, w3)`.
730fn parse_weight_list(s: &str) -> Vec<f64> {
731    let mut weights = Vec::new();
732    let mut depth = 0i32;
733    let mut current = String::new();
734
735    for ch in s.chars() {
736        match ch {
737            '(' => {
738                depth += 1;
739            }
740            ')' => {
741                depth -= 1;
742                if depth < 0 {
743                    // Closing paren of the outer RATIONAL section.
744                    let trimmed = current.trim();
745                    if let Ok(v) = trimmed.parse::<f64>() {
746                        weights.push(v);
747                    }
748                    break;
749                }
750            }
751            ',' if depth <= 1 => {
752                let trimmed = current.trim();
753                if let Ok(v) = trimmed.parse::<f64>() {
754                    weights.push(v);
755                }
756                current.clear();
757                continue;
758            }
759            ',' => {
760                // Comma inside a nested sub-list (depth > 1) — flush token
761                // without accumulating the comma character.
762                let trimmed = current.trim();
763                if let Ok(v) = trimmed.parse::<f64>() {
764                    weights.push(v);
765                }
766                current.clear();
767                continue;
768            }
769            _ => {}
770        }
771        if depth >= 0 && ch != '(' && ch != ')' {
772            current.push(ch);
773        }
774    }
775
776    weights
777}
778
779/// Extract a 2D weight grid from RATIONAL_B_SPLINE_SURFACE attrs.
780/// Returns uniform weights if parsing fails.
781fn extract_rational_weight_grid(attrs: &str, n_rows: usize, n_cols: usize) -> Vec<Vec<f64>> {
782    let flat = extract_rational_weights(attrs, n_rows * n_cols);
783    let tol = brepkit_math::tolerance::Tolerance::new();
784    if flat.len() == n_rows * n_cols && flat.iter().any(|&w| !tol.approx_eq(w, 1.0)) {
785        flat.chunks(n_cols).map(<[f64]>::to_vec).collect()
786    } else {
787        vec![vec![1.0; n_cols]; n_rows]
788    }
789}
790
791/// Parse a B_SPLINE_SURFACE_WITH_KNOTS attribute string into its components.
792///
793/// Format: `'', degree_u, degree_v, ((#cp, ...), ...), .XXX., .F., .F., .F.,
794///          (mult_u, ...), (mult_v, ...), (knot_u, ...), (knot_v, ...), .XXX.`
795///
796/// Returns: `(degree_u, degree_v, cp_grid_refs, u_mults, v_mults, u_knots, v_knots)`
797#[allow(clippy::type_complexity)]
798fn parse_bspline_surface_attrs(
799    attrs: &str,
800) -> Option<(
801    usize,
802    usize,
803    Vec<Vec<u64>>,
804    Vec<u32>,
805    Vec<u32>,
806    Vec<f64>,
807    Vec<f64>,
808)> {
809    // Strategy: parse the attribute string by finding the nested parenthesized
810    // structures. The format has a specific sequence of tokens.
811
812    // 1. Parse degrees: skip the name string, find the first two bare integers.
813    let mut tokens = Vec::new();
814    let mut depth = 0i32;
815    let mut current = String::new();
816    let mut groups: Vec<String> = Vec::new();
817
818    for ch in attrs.chars() {
819        match ch {
820            '(' => {
821                if depth == 0 && !current.trim().is_empty() {
822                    tokens.push(current.trim().to_string());
823                    current.clear();
824                }
825                depth += 1;
826                current.push(ch);
827            }
828            ')' => {
829                current.push(ch);
830                depth -= 1;
831                if depth == 0 {
832                    groups.push(current.clone());
833                    current.clear();
834                }
835            }
836            ',' if depth == 0 => {
837                let trimmed = current.trim().to_string();
838                if !trimmed.is_empty() {
839                    tokens.push(trimmed);
840                }
841                current.clear();
842            }
843            _ => {
844                current.push(ch);
845            }
846        }
847    }
848    if !current.trim().is_empty() {
849        tokens.push(current.trim().to_string());
850    }
851
852    // tokens: bare values between top-level commas (name, degrees, enums)
853    // groups: parenthesized structures at depth 0 (cp grid, mult lists, knot lists)
854
855    // Extract degrees from tokens (skip '' name string and .XXX. enum values).
856    let mut degrees: Vec<usize> = Vec::new();
857    for tok in &tokens {
858        if tok.starts_with('\'') || tok.starts_with('.') {
859            continue;
860        }
861        if let Ok(d) = tok.parse::<usize>() {
862            degrees.push(d);
863        }
864    }
865
866    if degrees.len() < 2 {
867        return None;
868    }
869    let degree_u = degrees[0];
870    let degree_v = degrees[1];
871
872    // groups should have at least 5 items:
873    // [0]: control point grid ((#cp, ...), ...)
874    // [1]: u multiplicities (m1, m2, ...)
875    // [2]: v multiplicities (m1, m2, ...)
876    // [3]: u knots (k1, k2, ...)
877    // [4]: v knots (k1, k2, ...)
878    if groups.len() < 5 {
879        return None;
880    }
881
882    // Parse control point grid: nested ((#1, #2), (#3, #4))
883    let cp_grid = parse_nested_refs(&groups[0]);
884
885    let u_mults = parse_ints_in_parens(&groups[1]);
886    let v_mults = parse_ints_in_parens(&groups[2]);
887
888    let u_knots = parse_floats(&groups[3]);
889    let v_knots = parse_floats(&groups[4]);
890
891    Some((
892        degree_u, degree_v, cp_grid, u_mults, v_mults, u_knots, v_knots,
893    ))
894}
895
896/// Parse nested `((#1, #2), (#3, #4))` into a Vec of Vec of entity refs.
897fn parse_nested_refs(s: &str) -> Vec<Vec<u64>> {
898    let mut rows: Vec<Vec<u64>> = Vec::new();
899    let mut depth = 0i32;
900    let mut current = String::new();
901
902    for ch in s.chars() {
903        match ch {
904            '(' => {
905                depth += 1;
906                if depth >= 2 {
907                    current.push(ch);
908                }
909            }
910            ')' => {
911                if depth >= 2 {
912                    current.push(ch);
913                }
914                depth -= 1;
915                if depth == 1 && !current.is_empty() {
916                    // End of an inner row.
917                    rows.push(parse_refs(&current));
918                    current.clear();
919                }
920            }
921            ',' if depth == 1 => {
922                // Separator between rows — flush current if non-empty.
923                if !current.is_empty() {
924                    rows.push(parse_refs(&current));
925                    current.clear();
926                }
927            }
928            _ => {
929                if depth >= 2 {
930                    current.push(ch);
931                }
932            }
933        }
934    }
935
936    rows
937}
938
939/// Expand knot multiplicities and unique values into a flat knot vector.
940///
941/// Given `mults = [3, 1, 3]` and `vals = [0.0, 0.5, 1.0]`, produces
942/// `[0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0]`.
943fn expand_knots(mults: &[u32], vals: &[f64]) -> Vec<f64> {
944    let mut knots = Vec::new();
945    for (&m, &v) in mults.iter().zip(vals.iter()) {
946        for _ in 0..m {
947            knots.push(v);
948        }
949    }
950    knots
951}
952
953/// Parse a B_SPLINE_CURVE_WITH_KNOTS attribute string.
954///
955/// Format: `'', degree, (#cp, ...), .XXX., .F., (mults), (knots), .XXX.`
956///
957/// Returns: `(degree, cp_refs, mults, knots)`
958#[allow(clippy::type_complexity)]
959fn parse_bspline_curve_attrs(attrs: &str) -> Option<(usize, Vec<u64>, Vec<u32>, Vec<f64>)> {
960    let mut tokens = Vec::new();
961    let mut depth = 0i32;
962    let mut current = String::new();
963    let mut groups: Vec<String> = Vec::new();
964
965    for ch in attrs.chars() {
966        match ch {
967            '(' => {
968                if depth == 0 && !current.trim().is_empty() {
969                    tokens.push(current.trim().to_string());
970                    current.clear();
971                }
972                depth += 1;
973                current.push(ch);
974            }
975            ')' => {
976                current.push(ch);
977                depth -= 1;
978                if depth == 0 {
979                    groups.push(current.clone());
980                    current.clear();
981                }
982            }
983            ',' if depth == 0 => {
984                let trimmed = current.trim().to_string();
985                if !trimmed.is_empty() {
986                    tokens.push(trimmed);
987                }
988                current.clear();
989            }
990            _ => {
991                current.push(ch);
992            }
993        }
994    }
995    if !current.trim().is_empty() {
996        tokens.push(current.trim().to_string());
997    }
998
999    let mut degree = None;
1000    for tok in &tokens {
1001        if tok.starts_with('\'') || tok.starts_with('.') {
1002            continue;
1003        }
1004        if let Ok(d) = tok.parse::<usize>() {
1005            degree = Some(d);
1006            break;
1007        }
1008    }
1009    let degree = degree?;
1010
1011    // groups: [0] = control points, [1] = multiplicities, [2] = knots
1012    if groups.len() < 3 {
1013        return None;
1014    }
1015
1016    let cp_refs = parse_refs(&groups[0]);
1017    let mults = parse_ints_in_parens(&groups[1]);
1018    let knots = parse_floats(&groups[2]);
1019
1020    Some((degree, cp_refs, mults, knots))
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025    #![allow(clippy::unwrap_used, clippy::expect_used)]
1026
1027    use brepkit_topology::Topology;
1028    use brepkit_topology::test_utils::make_unit_cube_non_manifold;
1029
1030    use super::*;
1031    use crate::step::writer;
1032
1033    #[test]
1034    fn roundtrip_unit_cube() {
1035        let mut write_topo = Topology::new();
1036        let solid = make_unit_cube_non_manifold(&mut write_topo);
1037
1038        let step_str = writer::write_step(&write_topo, &[solid]).unwrap();
1039
1040        let mut read_topo = Topology::new();
1041        let solids = read_step(&step_str, &mut read_topo).unwrap();
1042
1043        assert_eq!(solids.len(), 1);
1044
1045        let read_solid = read_topo.solid(solids[0]).unwrap();
1046        let shell = read_topo.shell(read_solid.outer_shell()).unwrap();
1047        assert_eq!(shell.faces().len(), 6);
1048    }
1049
1050    #[test]
1051    fn roundtrip_box_primitive() {
1052        let mut write_topo = Topology::new();
1053        let solid =
1054            brepkit_operations::primitives::make_box(&mut write_topo, 2.0, 3.0, 4.0).unwrap();
1055
1056        let step_str = writer::write_step(&write_topo, &[solid]).unwrap();
1057
1058        let mut read_topo = Topology::new();
1059        let solids = read_step(&step_str, &mut read_topo).unwrap();
1060
1061        assert_eq!(solids.len(), 1);
1062        let read_solid = read_topo.solid(solids[0]).unwrap();
1063        let shell = read_topo.shell(read_solid.outer_shell()).unwrap();
1064        assert_eq!(shell.faces().len(), 6);
1065    }
1066
1067    #[test]
1068    fn roundtrip_multiple_solids() {
1069        let mut write_topo = Topology::new();
1070        let s1 = brepkit_operations::primitives::make_box(&mut write_topo, 1.0, 1.0, 1.0).unwrap();
1071        let s2 = make_unit_cube_non_manifold(&mut write_topo);
1072
1073        let step_str = writer::write_step(&write_topo, &[s1, s2]).unwrap();
1074
1075        let mut read_topo = Topology::new();
1076        let solids = read_step(&step_str, &mut read_topo).unwrap();
1077
1078        assert_eq!(solids.len(), 2);
1079    }
1080
1081    #[test]
1082    fn roundtrip_faces_have_wires() {
1083        let mut write_topo = Topology::new();
1084        let solid = make_unit_cube_non_manifold(&mut write_topo);
1085
1086        let step_str = writer::write_step(&write_topo, &[solid]).unwrap();
1087
1088        let mut read_topo = Topology::new();
1089        let solids = read_step(&step_str, &mut read_topo).unwrap();
1090
1091        let read_solid = read_topo.solid(solids[0]).unwrap();
1092        let shell = read_topo.shell(read_solid.outer_shell()).unwrap();
1093
1094        for &face_id in shell.faces() {
1095            let face = read_topo.face(face_id).unwrap();
1096            let wire = read_topo.wire(face.outer_wire()).unwrap();
1097            assert_eq!(wire.edges().len(), 4, "cube face should have 4 edges");
1098        }
1099    }
1100
1101    #[test]
1102    fn roundtrip_faces_are_planar() {
1103        let mut write_topo = Topology::new();
1104        let solid = make_unit_cube_non_manifold(&mut write_topo);
1105
1106        let step_str = writer::write_step(&write_topo, &[solid]).unwrap();
1107
1108        let mut read_topo = Topology::new();
1109        let solids = read_step(&step_str, &mut read_topo).unwrap();
1110
1111        let read_solid = read_topo.solid(solids[0]).unwrap();
1112        let shell = read_topo.shell(read_solid.outer_shell()).unwrap();
1113
1114        for &face_id in shell.faces() {
1115            let face = read_topo.face(face_id).unwrap();
1116            assert!(matches!(face.surface(), FaceSurface::Plane { .. }));
1117        }
1118    }
1119
1120    #[test]
1121    fn empty_input_error() {
1122        let mut topo = Topology::new();
1123        let result = read_step("", &mut topo);
1124        assert!(result.is_err());
1125    }
1126
1127    #[test]
1128    fn no_data_section_error() {
1129        let mut topo = Topology::new();
1130        let result = read_step("ISO-10303-21;\nHEADER;\nENDSEC;\n", &mut topo);
1131        assert!(result.is_err());
1132    }
1133
1134    #[test]
1135    fn parse_refs_basic() {
1136        let refs = parse_refs("'', #10, #20, #30");
1137        assert_eq!(refs, vec![10, 20, 30]);
1138    }
1139
1140    #[test]
1141    fn parse_list_refs_basic() {
1142        let refs = parse_list_refs("'name', (#1, #2, #3), #4");
1143        assert_eq!(refs, vec![1, 2, 3]);
1144    }
1145
1146    #[test]
1147    fn parse_floats_basic() {
1148        let floats = parse_floats("'', (1.5, -2.3, 0.)");
1149        assert_eq!(floats.len(), 3);
1150        assert!((floats[0] - 1.5).abs() < 1e-10);
1151        assert!((floats[1] - (-2.3)).abs() < 1e-10);
1152        assert!((floats[2]).abs() < 1e-10);
1153    }
1154
1155    #[test]
1156    fn parse_floats_scientific() {
1157        let floats = parse_floats("'', (1.000000000000000E+00, -5.000000000000000E-01, 0.)");
1158        assert_eq!(floats.len(), 3);
1159        assert!((floats[0] - 1.0).abs() < 1e-10);
1160        assert!((floats[1] - (-0.5)).abs() < 1e-10);
1161    }
1162
1163    #[test]
1164    fn roundtrip_cylinder_preserves_surface() {
1165        let mut write_topo = Topology::new();
1166        let solid =
1167            brepkit_operations::primitives::make_cylinder(&mut write_topo, 1.5, 3.0).unwrap();
1168
1169        let step_str = writer::write_step(&write_topo, &[solid]).unwrap();
1170
1171        assert!(step_str.contains("CYLINDRICAL_SURFACE"));
1172
1173        let mut read_topo = Topology::new();
1174        let solids = read_step(&step_str, &mut read_topo).unwrap();
1175        assert!(!solids.is_empty(), "should import at least one solid");
1176
1177        let read_solid = read_topo.solid(solids[0]).unwrap();
1178        let shell = read_topo.shell(read_solid.outer_shell()).unwrap();
1179
1180        let has_cylinder = shell.faces().iter().any(|&fid| {
1181            matches!(
1182                read_topo.face(fid).unwrap().surface(),
1183                FaceSurface::Cylinder(_)
1184            )
1185        });
1186        assert!(
1187            has_cylinder,
1188            "imported cylinder should have a cylindrical face"
1189        );
1190    }
1191
1192    #[test]
1193    fn roundtrip_nurbs_surface_loft() {
1194        // Create a NURBS-surfaced solid via loft_smooth (3 profiles → NURBS sides).
1195        let mut write_topo = Topology::new();
1196
1197        let mut profiles = Vec::new();
1198        for &z in &[0.0, 1.0, 2.0] {
1199            let pts = vec![
1200                Point3::new(-1.0, -1.0, z),
1201                Point3::new(1.0, -1.0, z),
1202                Point3::new(1.0, 1.0, z),
1203                Point3::new(-1.0, 1.0, z),
1204            ];
1205            let wire_id =
1206                brepkit_topology::builder::make_polygon_wire(&mut write_topo, &pts, 1e-7).unwrap();
1207            let v01 = Vec3::new(
1208                pts[1].x() - pts[0].x(),
1209                pts[1].y() - pts[0].y(),
1210                pts[1].z() - pts[0].z(),
1211            );
1212            let v02 = Vec3::new(
1213                pts[2].x() - pts[0].x(),
1214                pts[2].y() - pts[0].y(),
1215                pts[2].z() - pts[0].z(),
1216            );
1217            let normal = v01.cross(v02).normalize().unwrap();
1218            let d = normal.x() * pts[0].x() + normal.y() * pts[0].y() + normal.z() * pts[0].z();
1219            let face = Face::new(wire_id, Vec::new(), FaceSurface::Plane { normal, d });
1220            profiles.push(write_topo.add_face(face));
1221        }
1222        let solid = brepkit_operations::loft::loft_smooth(&mut write_topo, &profiles).unwrap();
1223
1224        let orig_solid = write_topo.solid(solid).unwrap();
1225        let orig_shell = write_topo.shell(orig_solid.outer_shell()).unwrap();
1226        let orig_nurbs_count = orig_shell
1227            .faces()
1228            .iter()
1229            .filter(|&&fid| {
1230                matches!(
1231                    write_topo.face(fid).unwrap().surface(),
1232                    FaceSurface::Nurbs(_)
1233                )
1234            })
1235            .count();
1236        assert!(orig_nurbs_count > 0, "lofted solid should have NURBS faces");
1237
1238        let step_str = writer::write_step(&write_topo, &[solid]).unwrap();
1239        assert!(
1240            step_str.contains("B_SPLINE_SURFACE_WITH_KNOTS"),
1241            "STEP output should contain B_SPLINE_SURFACE_WITH_KNOTS"
1242        );
1243
1244        let mut read_topo = Topology::new();
1245        let solids = read_step(&step_str, &mut read_topo).unwrap();
1246        assert!(!solids.is_empty(), "should import at least one solid");
1247
1248        let read_solid = read_topo.solid(solids[0]).unwrap();
1249        let shell = read_topo.shell(read_solid.outer_shell()).unwrap();
1250
1251        let nurbs_count = shell
1252            .faces()
1253            .iter()
1254            .filter(|&&fid| {
1255                matches!(
1256                    read_topo.face(fid).unwrap().surface(),
1257                    FaceSurface::Nurbs(_)
1258                )
1259            })
1260            .count();
1261        assert!(
1262            nurbs_count > 0,
1263            "imported solid should have NURBS faces (got {nurbs_count})"
1264        );
1265        assert_eq!(
1266            nurbs_count, orig_nurbs_count,
1267            "NURBS face count should be preserved: {orig_nurbs_count} → {nurbs_count}"
1268        );
1269    }
1270
1271    #[test]
1272    fn roundtrip_nurbs_curve_preserved() {
1273        // Create a solid with NURBS edge curves (e.g., via loft_smooth).
1274        let mut write_topo = Topology::new();
1275
1276        let mut profiles = Vec::new();
1277        for &z in &[0.0, 1.0, 2.0] {
1278            let pts = vec![
1279                Point3::new(-1.0, -1.0, z),
1280                Point3::new(1.0, -1.0, z),
1281                Point3::new(1.0, 1.0, z),
1282                Point3::new(-1.0, 1.0, z),
1283            ];
1284            let wire_id =
1285                brepkit_topology::builder::make_polygon_wire(&mut write_topo, &pts, 1e-7).unwrap();
1286            let v01 = Vec3::new(
1287                pts[1].x() - pts[0].x(),
1288                pts[1].y() - pts[0].y(),
1289                pts[1].z() - pts[0].z(),
1290            );
1291            let v02 = Vec3::new(
1292                pts[2].x() - pts[0].x(),
1293                pts[2].y() - pts[0].y(),
1294                pts[2].z() - pts[0].z(),
1295            );
1296            let normal = v01.cross(v02).normalize().unwrap();
1297            let d = normal.x() * pts[0].x() + normal.y() * pts[0].y() + normal.z() * pts[0].z();
1298            let face = Face::new(wire_id, Vec::new(), FaceSurface::Plane { normal, d });
1299            profiles.push(write_topo.add_face(face));
1300        }
1301        let solid = brepkit_operations::loft::loft_smooth(&mut write_topo, &profiles).unwrap();
1302
1303        let step_str = writer::write_step(&write_topo, &[solid]).unwrap();
1304
1305        let has_bspline_curve = step_str.contains("B_SPLINE_CURVE_WITH_KNOTS");
1306
1307        if has_bspline_curve {
1308            let mut read_topo = Topology::new();
1309            let solids = read_step(&step_str, &mut read_topo).unwrap();
1310            assert!(!solids.is_empty());
1311
1312            let read_solid = read_topo.solid(solids[0]).unwrap();
1313            let shell = read_topo.shell(read_solid.outer_shell()).unwrap();
1314
1315            let has_nurbs_curve = shell.faces().iter().any(|&fid| {
1316                let face = read_topo.face(fid).unwrap();
1317                let wire = read_topo.wire(face.outer_wire()).unwrap();
1318                wire.edges().iter().any(|he| {
1319                    matches!(
1320                        read_topo.edge(he.edge()).unwrap().curve(),
1321                        EdgeCurve::NurbsCurve(_)
1322                    )
1323                })
1324            });
1325            assert!(
1326                has_nurbs_curve,
1327                "imported solid should have NURBS edge curves"
1328            );
1329        }
1330        // If no B_SPLINE_CURVE_WITH_KNOTS in output, the loft only produces
1331        // Line edges (which is valid for square profiles). Skip the curve check.
1332    }
1333
1334    #[test]
1335    fn roundtrip_circle_edge_preserved() {
1336        // Cylinder has circle edges — they should round-trip.
1337        let mut write_topo = Topology::new();
1338        let solid =
1339            brepkit_operations::primitives::make_cylinder(&mut write_topo, 1.0, 2.0).unwrap();
1340
1341        let step_str = writer::write_step(&write_topo, &[solid]).unwrap();
1342        assert!(step_str.contains("CIRCLE"));
1343
1344        let mut read_topo = Topology::new();
1345        let solids = read_step(&step_str, &mut read_topo).unwrap();
1346        assert!(!solids.is_empty());
1347
1348        let read_solid = read_topo.solid(solids[0]).unwrap();
1349        let shell = read_topo.shell(read_solid.outer_shell()).unwrap();
1350
1351        let has_circle = shell.faces().iter().any(|&fid| {
1352            let face = read_topo.face(fid).unwrap();
1353            let wire = read_topo.wire(face.outer_wire()).unwrap();
1354            wire.edges().iter().any(|he| {
1355                matches!(
1356                    read_topo.edge(he.edge()).unwrap().curve(),
1357                    EdgeCurve::Circle(_)
1358                )
1359            })
1360        });
1361        assert!(
1362            has_circle,
1363            "imported cylinder should have circle edge curves"
1364        );
1365    }
1366
1367    #[test]
1368    fn parse_bspline_surface_attrs_basic() {
1369        // Minimal B_SPLINE_SURFACE_WITH_KNOTS attribute string.
1370        let attrs = "'', 1, 1, ((#10, #11), (#12, #13)), .UNSPECIFIED., .F., .F., .F., \
1371                     (2, 2), (2, 2), (0.0, 1.0), (0.0, 1.0), .UNSPECIFIED.";
1372        let result = parse_bspline_surface_attrs(attrs);
1373        assert!(result.is_some(), "should parse B_SPLINE_SURFACE attributes");
1374        let (deg_u, deg_v, cp_grid, u_mults, v_mults, u_knots, v_knots) = result.unwrap();
1375        assert_eq!(deg_u, 1);
1376        assert_eq!(deg_v, 1);
1377        assert_eq!(cp_grid.len(), 2);
1378        assert_eq!(cp_grid[0].len(), 2);
1379        assert_eq!(u_mults, vec![2, 2]);
1380        assert_eq!(v_mults, vec![2, 2]);
1381        assert_eq!(u_knots, vec![0.0, 1.0]);
1382        assert_eq!(v_knots, vec![0.0, 1.0]);
1383    }
1384
1385    #[test]
1386    fn parse_bspline_curve_attrs_basic() {
1387        let attrs = "'', 3, (#1, #2, #3, #4), .UNSPECIFIED., .F., .F., \
1388                     (4, 4), (0.0, 1.0), .UNSPECIFIED.";
1389        let result = parse_bspline_curve_attrs(attrs);
1390        assert!(result.is_some(), "should parse B_SPLINE_CURVE attributes");
1391        let (degree, cp_refs, mults, knots) = result.unwrap();
1392        assert_eq!(degree, 3);
1393        assert_eq!(cp_refs.len(), 4);
1394        assert_eq!(mults, vec![4, 4]);
1395        assert_eq!(knots, vec![0.0, 1.0]);
1396    }
1397
1398    #[test]
1399    fn expand_knots_basic() {
1400        let mults = [3, 1, 3];
1401        let vals = [0.0, 0.5, 1.0];
1402        let flat = expand_knots(&mults, &vals);
1403        assert_eq!(flat, vec![0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0]);
1404    }
1405
1406    #[test]
1407    fn parse_weight_list_nested() {
1408        // Nested format: ((w1, w2, w3))
1409        let weights = parse_weight_list("(1.0, 0.707, 1.0))");
1410        assert_eq!(weights.len(), 3);
1411        assert!((weights[0] - 1.0).abs() < 1e-10);
1412        assert!((weights[1] - 0.707).abs() < 1e-10);
1413        assert!((weights[2] - 1.0).abs() < 1e-10);
1414    }
1415
1416    #[test]
1417    fn parse_weight_list_flat() {
1418        // Flat format: (w1, w2, w3) — no inner parens
1419        let weights = parse_weight_list("1.0, 0.707, 1.0)");
1420        assert_eq!(weights.len(), 3);
1421        assert!((weights[0] - 1.0).abs() < 1e-10);
1422        assert!((weights[1] - 0.707).abs() < 1e-10);
1423        assert!((weights[2] - 1.0).abs() < 1e-10);
1424    }
1425
1426    #[test]
1427    fn parse_weight_list_scientific() {
1428        // Scientific notation
1429        let weights = parse_weight_list("(1.000000E+00, 7.071068E-01))");
1430        assert_eq!(weights.len(), 2);
1431        assert!((weights[0] - 1.0).abs() < 1e-5);
1432        assert!((weights[1] - std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-5);
1433    }
1434
1435    #[test]
1436    fn parse_weight_list_2d_nested() {
1437        // 2D nested format: ((w1, w2), (w3, w4)) — real STEP has double nesting
1438        let weights = parse_weight_list("((1.0, 0.5), (0.5, 1.0)))");
1439        assert_eq!(weights.len(), 4);
1440        assert!((weights[0] - 1.0).abs() < 1e-10);
1441        assert!((weights[1] - 0.5).abs() < 1e-10);
1442        assert!((weights[2] - 0.5).abs() < 1e-10);
1443        assert!((weights[3] - 1.0).abs() < 1e-10);
1444    }
1445
1446    #[test]
1447    fn extract_rational_weights_from_composite() {
1448        let attrs = "BOUNDED_CURVE() B_SPLINE_CURVE(2, (#1, #2, #3)) \
1449                     B_SPLINE_CURVE_WITH_KNOTS((3,3), (0.0, 1.0)) \
1450                     RATIONAL_B_SPLINE_CURVE((1.0, 0.707, 1.0))";
1451        let weights = extract_rational_weights(attrs, 3);
1452        assert_eq!(weights.len(), 3);
1453        assert!((weights[1] - 0.707).abs() < 1e-10);
1454    }
1455}