1use geop_core_program::reader::Reader;
23use std::collections::HashMap;
24
25use geop_core_editor::output::{Content, Export, Exporter, ExporterInfo, NoOptions, Output};
26use geop_core_geometry::{nurb_curve::NurbCurve3D, nurb_surface::NurbSurface3D, search};
27use geop_core_math::{
28 geop_error::{GeopError, GeopResult},
29 primitives::Pose,
30 scalars::Scalar,
31 vector::Vector3,
32};
33use geop_core_topology::{
34 CoedgeGeometry, EdgeId, FaceId, Model, Sense, ShellId, VertexId, boundary::BoundaryType,
35 contains::shell::solid_lumps,
36};
37use geop_ops_model::parameters::{read_properties, stem};
38use geop_ops_model::placed::placed_parts;
39use geop_ops_model::{NamedModel, RefId, read_model};
40
41use crate::part21::{Exchange, Instance, Record, Value};
42
43const LUMPS_SEED: u64 = 0x05EE_D0F1_A3B5;
46
47const UNCERTAINTY: f64 = 1e-7;
52
53pub fn write_step(model: &NamedModel, name: &str) -> GeopResult<String> {
56 let mut writer = Writer::default();
57 let context = writer.context();
58 writer.part(model, &[], name, &context)?;
59 Ok(writer.file(name))
60}
61
62pub fn write_part(reader: &Reader, name: &str) -> GeopResult<String> {
65 let mut writer = Writer::default();
66 let context = writer.context();
67 writer.assembly(reader, name, &context)?;
68 Ok(writer.file(name))
69}
70
71#[derive(Clone, Copy)]
73struct Written {
74 definition: u64,
76 shape: u64,
78 origin: u64,
80}
81
82impl Writer {
83 fn file(self, name: &str) -> String {
86 let file_name = format!("{name}.step");
87 Exchange {
88 header: vec![
89 Record {
90 name: "FILE_DESCRIPTION".into(),
91 args: vec![Value::List(vec![string(name)]), string("2;1")],
92 },
93 Record {
94 name: "FILE_NAME".into(),
95 args: vec![
96 string(&file_name),
97 string(""),
98 Value::List(vec![string("")]),
99 Value::List(vec![string("")]),
100 string("geop"),
101 string("geop"),
102 string(""),
103 ],
104 },
105 Record {
106 name: "FILE_SCHEMA".into(),
107 args: vec![Value::List(vec![string(
108 "AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }",
109 )])],
110 },
111 ],
112 instances: self.instances,
113 }
114 .write()
115 }
116}
117
118fn string(s: &str) -> Value {
119 Value::String(s.to_string())
120}
121
122fn logical(b: bool) -> Value {
123 Value::Enum(if b { "T" } else { "F" }.into())
124}
125
126fn reals(xs: impl IntoIterator<Item = f64>) -> Value {
127 Value::List(xs.into_iter().map(Value::Real).collect())
128}
129
130fn refs(ids: impl IntoIterator<Item = u64>) -> Value {
131 Value::List(ids.into_iter().map(Value::Ref).collect())
132}
133
134fn knots_and_multiplicities(knots: &[Scalar]) -> (Value, Value) {
138 let mut distinct: Vec<f64> = Vec::new();
139 let mut counts: Vec<i64> = Vec::new();
140 for k in knots {
141 let k = k.to_f64();
142 if distinct.last() == Some(&k) {
143 *counts.last_mut().expect("a knot was pushed with it") += 1;
144 } else {
145 distinct.push(k);
146 counts.push(1);
147 }
148 }
149 (
150 Value::List(counts.into_iter().map(Value::Integer).collect()),
151 reals(distinct),
152 )
153}
154
155struct Context {
157 application: u64,
158 representation: u64,
159}
160
161#[derive(Default)]
162struct Writer {
163 instances: std::collections::BTreeMap<u64, Instance>,
164}
165
166impl Writer {
167 fn insert(&mut self, instance: Instance) -> u64 {
168 let id = self.instances.len() as u64 + 1;
169 self.instances.insert(id, instance);
170 id
171 }
172
173 fn add(&mut self, name: &str, args: Vec<Value>) -> u64 {
174 self.insert(Instance::Simple(Record {
175 name: name.into(),
176 args,
177 }))
178 }
179
180 fn add_complex(&mut self, mut records: Vec<Record>) -> u64 {
183 records.sort_by(|a, b| a.name.cmp(&b.name));
184 self.insert(Instance::Complex(records))
185 }
186
187 fn point(&mut self, p: [f64; 3]) -> u64 {
188 self.add("CARTESIAN_POINT", vec![string(""), reals(p)])
189 }
190
191 fn placement(&mut self, origin: [f64; 3], axis: [f64; 3], reference: [f64; 3]) -> u64 {
192 let origin = self.point(origin);
193 let axis = self.add("DIRECTION", vec![string(""), reals(axis)]);
194 let reference = self.add("DIRECTION", vec![string(""), reals(reference)]);
195 self.add(
196 "AXIS2_PLACEMENT_3D",
197 vec![
198 string(""),
199 Value::Ref(origin),
200 Value::Ref(axis),
201 Value::Ref(reference),
202 ],
203 )
204 }
205
206 fn context(&mut self) -> Context {
209 let application = self.add(
210 "APPLICATION_CONTEXT",
211 vec![string(
212 "core data for automotive mechanical design processes",
213 )],
214 );
215 self.add(
216 "APPLICATION_PROTOCOL_DEFINITION",
217 vec![
218 string("international standard"),
219 string("automotive_design"),
220 Value::Integer(2000),
221 Value::Ref(application),
222 ],
223 );
224 let record = |name: &str, args: Vec<Value>| Record {
225 name: name.into(),
226 args,
227 };
228 let length_unit = self.add_complex(vec![
229 record("LENGTH_UNIT", vec![]),
230 record("NAMED_UNIT", vec![Value::Derived]),
231 record(
232 "SI_UNIT",
233 vec![Value::Enum("MILLI".into()), Value::Enum("METRE".into())],
234 ),
235 ]);
236 let angle_unit = self.add_complex(vec![
237 record("NAMED_UNIT", vec![Value::Derived]),
238 record("PLANE_ANGLE_UNIT", vec![]),
239 record("SI_UNIT", vec![Value::Null, Value::Enum("RADIAN".into())]),
240 ]);
241 let solid_angle_unit = self.add_complex(vec![
242 record("NAMED_UNIT", vec![Value::Derived]),
243 record(
244 "SI_UNIT",
245 vec![Value::Null, Value::Enum("STERADIAN".into())],
246 ),
247 record("SOLID_ANGLE_UNIT", vec![]),
248 ]);
249 let uncertainty = self.add(
250 "UNCERTAINTY_MEASURE_WITH_UNIT",
251 vec![
252 Value::Typed("LENGTH_MEASURE".into(), Box::new(Value::Real(UNCERTAINTY))),
253 Value::Ref(length_unit),
254 string("distance_accuracy_value"),
255 string("confusion accuracy"),
256 ],
257 );
258 let representation = self.add_complex(vec![
259 record("GEOMETRIC_REPRESENTATION_CONTEXT", vec![Value::Integer(3)]),
260 record(
261 "GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT",
262 vec![refs([uncertainty])],
263 ),
264 record(
265 "GLOBAL_UNIT_ASSIGNED_CONTEXT",
266 vec![refs([length_unit, angle_unit, solid_angle_unit])],
267 ),
268 record("REPRESENTATION_CONTEXT", vec![string("geop"), string("3D")]),
269 ]);
270 Context {
271 application,
272 representation,
273 }
274 }
275
276 fn product(&mut self, name: &str, application: u64) -> (u64, u64) {
280 let context = self.add(
281 "PRODUCT_CONTEXT",
282 vec![string(""), Value::Ref(application), string("mechanical")],
283 );
284 let product = self.add(
285 "PRODUCT",
286 vec![string(name), string(name), string(""), refs([context])],
287 );
288 self.add(
289 "PRODUCT_RELATED_PRODUCT_CATEGORY",
290 vec![string("part"), Value::Null, refs([product])],
291 );
292 let formation = self.add(
293 "PRODUCT_DEFINITION_FORMATION",
294 vec![string(""), string(""), Value::Ref(product)],
295 );
296 let definition_context = self.add(
297 "PRODUCT_DEFINITION_CONTEXT",
298 vec![
299 string("part definition"),
300 Value::Ref(application),
301 string("design"),
302 ],
303 );
304 let definition = self.add(
305 "PRODUCT_DEFINITION",
306 vec![
307 string("design"),
308 string(""),
309 Value::Ref(formation),
310 Value::Ref(definition_context),
311 ],
312 );
313 let shape = self.add(
314 "PRODUCT_DEFINITION_SHAPE",
315 vec![string(""), string(""), Value::Ref(definition)],
316 );
317 (definition, shape)
318 }
319
320 fn pose(&mut self, pose: &Pose) -> GeopResult<u64> {
322 let plain = |q: Vector3| q.to_f64_array();
323 let [x, _, z] = pose.rotation().rotation_columns()?;
325 Ok(self.placement(plain(pose.position()), plain(z), plain(x)))
326 }
327
328 fn assembly(&mut self, reader: &Reader, name: &str, context: &Context) -> GeopResult<Written> {
336 let ctx = |e: GeopError| e.with_context(format!("writing the part {name:?} as STEP"));
337 let mut placed = Vec::new();
338 let parts = placed_parts(reader).map_err(ctx)?;
339 for part in parts.iter().filter(|p| p.parent.is_none()) {
340 let inner = part.located.part.clone();
343 let properties = read_properties(&inner).map_err(ctx)?;
344 let stem = stem(&part.located.instance.file);
345 let name = (properties.designation.clone())
346 .unwrap_or_else(|| properties.name(stem).to_string());
347 let child = self.assembly(&inner, &name, context)?;
348 placed.push((part.name.clone(), child, part.located.local));
349 }
350 let model = read_model(reader).map_err(ctx)?;
351 self.part(model.as_ref(), &placed, name, context)
352 }
353
354 fn part(
359 &mut self,
360 model: &NamedModel,
361 placed: &[(String, Written, Pose)],
362 name: &str,
363 context: &Context,
364 ) -> GeopResult<Written> {
365 let mut solids = Vec::new();
366 let mut sheets = Vec::new();
367 self.bodies(model, &mut solids, &mut sheets)?;
368 let (definition, product_shape) = self.product(name, context.application);
369 let origin = self.placement([0.0; 3], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
370 let representation = |writer: &mut Self, kind: &str, items: &[u64]| {
371 let mut all = vec![Value::Ref(origin)];
372 all.extend(items.iter().map(|&id| Value::Ref(id)));
373 writer.add(
374 kind,
375 vec![
376 string(name),
377 Value::List(all),
378 Value::Ref(context.representation),
379 ],
380 )
381 };
382 let relate = |writer: &mut Self, a: u64, b: u64| {
383 writer.add(
384 "SHAPE_REPRESENTATION_RELATIONSHIP",
385 vec![string(""), string(""), Value::Ref(a), Value::Ref(b)],
386 );
387 };
388 let brep = (placed.is_empty() || !solids.is_empty())
389 .then(|| representation(self, "ADVANCED_BREP_SHAPE_REPRESENTATION", &solids));
390 let surfaces = (!sheets.is_empty())
391 .then(|| representation(self, "MANIFOLD_SURFACE_SHAPE_REPRESENTATION", &sheets));
392 let axes: Vec<u64> = (placed.iter())
393 .map(|(_, _, pose)| self.pose(pose))
394 .collect::<GeopResult<_>>()?;
395 let shape = match brep {
398 Some(brep) if placed.is_empty() => brep,
399 _ => {
400 let shape = representation(self, "SHAPE_REPRESENTATION", &axes);
401 if let Some(brep) = brep {
402 relate(self, shape, brep);
403 }
404 shape
405 }
406 };
407 if let Some(surfaces) = surfaces {
408 relate(self, shape, surfaces);
409 }
410 self.add(
411 "SHAPE_DEFINITION_REPRESENTATION",
412 vec![Value::Ref(product_shape), Value::Ref(shape)],
413 );
414
415 for ((instance, child, _), axis) in placed.iter().zip(axes) {
416 let occurrence = self.add(
417 "NEXT_ASSEMBLY_USAGE_OCCURRENCE",
418 vec![
419 string(instance),
420 string(instance),
421 string(""),
422 Value::Ref(definition),
423 Value::Ref(child.definition),
424 Value::Null,
425 ],
426 );
427 let occurrence_shape = self.add(
428 "PRODUCT_DEFINITION_SHAPE",
429 vec![string(""), string(""), Value::Ref(occurrence)],
430 );
431 let transformation = self.add(
432 "ITEM_DEFINED_TRANSFORMATION",
433 vec![
434 string(""),
435 string(""),
436 Value::Ref(child.origin),
437 Value::Ref(axis),
438 ],
439 );
440 let record = |name: &str, args: Vec<Value>| Record {
441 name: name.into(),
442 args,
443 };
444 let relationship = self.add_complex(vec![
445 record(
446 "REPRESENTATION_RELATIONSHIP",
447 vec![
448 string(""),
449 string(""),
450 Value::Ref(child.shape),
451 Value::Ref(shape),
452 ],
453 ),
454 record(
455 "REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION",
456 vec![Value::Ref(transformation)],
457 ),
458 record("SHAPE_REPRESENTATION_RELATIONSHIP", vec![]),
459 ]);
460 self.add(
461 "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION",
462 vec![Value::Ref(relationship), Value::Ref(occurrence_shape)],
463 );
464 }
465 Ok(Written {
466 definition,
467 shape,
468 origin,
469 })
470 }
471
472 fn bodies(
475 &mut self,
476 named: &NamedModel,
477 solids: &mut Vec<u64>,
478 sheets: &mut Vec<u64>,
479 ) -> GeopResult<()> {
480 let model = named.topology();
481 let mut body = BodyWriter {
482 writer: self,
483 model,
484 name: |id: RefId| named.name_of(id).unwrap_or_default().to_string(),
485 vertices: HashMap::new(),
486 edges: HashMap::new(),
487 };
488 for &solid_id in model.solids.keys() {
490 let name = (body.name)(solid_id.into());
491 let lumps = solid_lumps(
494 model,
495 solid_id,
496 search::MAX_NODES,
497 search::min_subdivision_size(),
498 LUMPS_SEED,
499 )?;
500 for (outer, voids) in lumps {
501 let outer = body.shell(outer, "CLOSED_SHELL", false)?;
502 let id = if voids.is_empty() {
503 body.writer.add(
504 "MANIFOLD_SOLID_BREP",
505 vec![string(&name), Value::Ref(outer)],
506 )
507 } else {
508 let mut oriented = Vec::new();
512 for void in voids {
513 let shell = body.shell(void, "CLOSED_SHELL", true)?;
514 oriented.push(body.writer.add(
515 "ORIENTED_CLOSED_SHELL",
516 vec![
517 string(""),
518 Value::Derived,
519 Value::Ref(shell),
520 logical(false),
521 ],
522 ));
523 }
524 body.writer.add(
525 "BREP_WITH_VOIDS",
526 vec![string(&name), Value::Ref(outer), refs(oriented)],
527 )
528 };
529 solids.push(id);
530 }
531 }
532 let sheet_ids = model
533 .shells
534 .iter()
535 .filter(|(_, shell)| shell.solid.is_none())
536 .map(|(&id, _)| id);
537 for sheet in sheet_ids {
538 let shell = body.shell(sheet, "OPEN_SHELL", false)?;
539 sheets.push(
540 body.writer
541 .add("SHELL_BASED_SURFACE_MODEL", vec![string(""), refs([shell])]),
542 );
543 }
544 Ok(())
545 }
546}
547
548struct BodyWriter<'w, 'm, N> {
551 writer: &'w mut Writer,
552 model: &'m Model,
553 name: N,
554 vertices: HashMap<VertexId, u64>,
555 edges: HashMap<EdgeId, u64>,
556}
557
558impl<N: Fn(RefId) -> String> BodyWriter<'_, '_, N> {
559 fn coordinates(p: &Vector3) -> [f64; 3] {
560 p.to_f64_array()
561 }
562
563 fn control_point(&mut self, cp: &geop_core_math::vector::Vector4) -> GeopResult<(u64, f64)> {
566 let p = Self::coordinates(&cp.dehomogenized()?);
567 Ok((self.writer.point(p), cp[3].to_f64()))
568 }
569
570 fn vertex(&mut self, id: VertexId) -> GeopResult<u64> {
571 if let Some(&step) = self.vertices.get(&id) {
572 return Ok(step);
573 }
574 let p = Self::coordinates(&self.model.get_vertex(id)?.point);
575 let point = self.writer.point(p);
576 let step = self.writer.add(
577 "VERTEX_POINT",
578 vec![string(&(self.name)(id.into())), Value::Ref(point)],
579 );
580 self.vertices.insert(id, step);
581 Ok(step)
582 }
583
584 fn curve(&mut self, curve: &NurbCurve3D) -> GeopResult<u64> {
585 let mut points = Vec::new();
586 let mut weights = Vec::new();
587 for cp in &curve.control_points {
588 let (point, weight) = self.control_point(cp)?;
589 points.push(point);
590 weights.push(weight);
591 }
592 let (multiplicities, knots) = knots_and_multiplicities(&curve.knot_vector);
593 let degree = Value::Integer(curve.degree as i64);
594 let form = Value::Enum("UNSPECIFIED".into());
595 if weights.iter().all(|&w| w == 1.0) {
596 return Ok(self.writer.add(
597 "B_SPLINE_CURVE_WITH_KNOTS",
598 vec![
599 string(""),
600 degree,
601 refs(points),
602 form.clone(),
603 logical(false),
604 logical(false),
605 multiplicities,
606 knots,
607 form,
608 ],
609 ));
610 }
611 let record = |name: &str, args: Vec<Value>| Record {
612 name: name.into(),
613 args,
614 };
615 Ok(self.writer.add_complex(vec![
616 record("BOUNDED_CURVE", vec![]),
617 record(
618 "B_SPLINE_CURVE",
619 vec![
620 degree,
621 refs(points),
622 form.clone(),
623 logical(false),
624 logical(false),
625 ],
626 ),
627 record(
628 "B_SPLINE_CURVE_WITH_KNOTS",
629 vec![multiplicities, knots, form],
630 ),
631 record("CURVE", vec![]),
632 record("GEOMETRIC_REPRESENTATION_ITEM", vec![]),
633 record("RATIONAL_B_SPLINE_CURVE", vec![reals(weights)]),
634 record("REPRESENTATION_ITEM", vec![string("")]),
635 ]))
636 }
637
638 fn surface(&mut self, surface: &NurbSurface3D) -> GeopResult<u64> {
639 let mut rows = Vec::new();
640 let mut weight_rows = Vec::new();
641 for i in 0..surface.num_u {
642 let mut row = Vec::new();
643 let mut weights = Vec::new();
644 for j in 0..surface.num_v {
645 let (point, weight) =
646 self.control_point(&surface.control_points[i * surface.num_v + j])?;
647 row.push(point);
648 weights.push(weight);
649 }
650 rows.push(refs(row));
651 weight_rows.push(weights);
652 }
653 let (u_multiplicities, u_knots) = knots_and_multiplicities(&surface.knot_vector_u);
654 let (v_multiplicities, v_knots) = knots_and_multiplicities(&surface.knot_vector_v);
655 let degree_u = Value::Integer(surface.degree_u as i64);
656 let degree_v = Value::Integer(surface.degree_v as i64);
657 let form = Value::Enum("UNSPECIFIED".into());
658 if weight_rows.iter().flatten().all(|&w| w == 1.0) {
659 return Ok(self.writer.add(
660 "B_SPLINE_SURFACE_WITH_KNOTS",
661 vec![
662 string(""),
663 degree_u,
664 degree_v,
665 Value::List(rows),
666 form.clone(),
667 logical(false),
668 logical(false),
669 logical(false),
670 u_multiplicities,
671 v_multiplicities,
672 u_knots,
673 v_knots,
674 form,
675 ],
676 ));
677 }
678 let record = |name: &str, args: Vec<Value>| Record {
679 name: name.into(),
680 args,
681 };
682 Ok(self.writer.add_complex(vec![
683 record("BOUNDED_SURFACE", vec![]),
684 record(
685 "B_SPLINE_SURFACE",
686 vec![
687 degree_u,
688 degree_v,
689 Value::List(rows),
690 form.clone(),
691 logical(false),
692 logical(false),
693 logical(false),
694 ],
695 ),
696 record(
697 "B_SPLINE_SURFACE_WITH_KNOTS",
698 vec![u_multiplicities, v_multiplicities, u_knots, v_knots, form],
699 ),
700 record("GEOMETRIC_REPRESENTATION_ITEM", vec![]),
701 record(
702 "RATIONAL_B_SPLINE_SURFACE",
703 vec![Value::List(weight_rows.into_iter().map(reals).collect())],
704 ),
705 record("REPRESENTATION_ITEM", vec![string("")]),
706 record("SURFACE", vec![]),
707 ]))
708 }
709
710 fn edge(&mut self, id: EdgeId) -> GeopResult<u64> {
711 if let Some(&step) = self.edges.get(&id) {
712 return Ok(step);
713 }
714 let edge = self.model.get_edge(id)?;
715 let start = self.vertex(edge.start_vertex)?;
716 let end = self.vertex(edge.end_vertex)?;
717 let curve = self.curve(&edge.curve)?;
718 let step = self.writer.add(
719 "EDGE_CURVE",
720 vec![
721 string(&(self.name)(id.into())),
722 Value::Ref(start),
723 Value::Ref(end),
724 Value::Ref(curve),
725 logical(true),
726 ],
727 );
728 self.edges.insert(id, step);
729 Ok(step)
730 }
731
732 fn bound(&mut self, boundary: BoundaryType, outer: bool, reversed: bool) -> GeopResult<u64> {
735 let lp = match boundary {
736 BoundaryType::Vertex(v) => {
737 let vertex = self.vertex(v)?;
738 self.writer
739 .add("VERTEX_LOOP", vec![string(""), Value::Ref(vertex)])
740 }
741 BoundaryType::Loop(anchor) => {
742 let mut oriented = Vec::new();
743 let coedges: Vec<_> = self.model.iterate_loop_coedges(anchor).collect();
744 for coedge_id in coedges {
745 let coedge = self.model.get_coedge(coedge_id)?;
746 let CoedgeGeometry::Edge(edge) = coedge.geometry else {
749 continue;
750 };
751 let forward = coedge.sense == Sense::Forward;
752 let edge = self.edge(edge)?;
753 oriented.push(self.writer.add(
754 "ORIENTED_EDGE",
755 vec![
756 string(""),
757 Value::Derived,
758 Value::Derived,
759 Value::Ref(edge),
760 logical(forward),
761 ],
762 ));
763 }
764 self.writer
765 .add("EDGE_LOOP", vec![string(""), refs(oriented)])
766 }
767 };
768 let kind = if outer {
769 "FACE_OUTER_BOUND"
770 } else {
771 "FACE_BOUND"
772 };
773 Ok(self
774 .writer
775 .add(kind, vec![string(""), Value::Ref(lp), logical(!reversed)]))
776 }
777
778 fn face(&mut self, id: FaceId, reversed: bool) -> GeopResult<u64> {
779 let face = self.model.get_face(id)?;
780 let surface = self.surface(&face.surface)?;
781 let mut bounds = vec![self.bound(face.outer, true, reversed)?];
782 for &hole in &face.holes {
783 bounds.push(self.bound(hole, false, reversed)?);
784 }
785 Ok(self.writer.add(
786 "ADVANCED_FACE",
787 vec![
788 string(&(self.name)(id.into())),
789 refs(bounds),
790 Value::Ref(surface),
791 logical(!reversed),
792 ],
793 ))
794 }
795
796 fn shell(&mut self, id: ShellId, kind: &str, reversed: bool) -> GeopResult<u64> {
799 let mut faces = self.model.get_shell(id)?.faces.clone();
800 faces.sort_by_key(|id| id.0);
801 let mut steps = Vec::new();
802 for face in faces {
803 steps.push(self.face(face, reversed)?);
804 }
805 Ok(self.writer.add(kind, vec![string(""), refs(steps)]))
806 }
807}
808
809pub struct Step;
813
814impl Exporter for Step {
815 const INFO: ExporterInfo = ExporterInfo {
816 kind: "step",
817 label: "STEP",
818 doc: "solids, for other CAD systems",
819 formats: &["step", "stp"],
820 };
821 type Options = NoOptions;
822
823 fn export(output: &mut Output, format: &str, _: NoOptions) -> GeopResult<Export> {
824 let name = stem(output.file_name());
825 let text = write_part(&output.part(), name)?;
826 Ok(Export {
827 name: format!("{name}.{format}"),
828 media_type: "model/step".into(),
829 content: Content::Text(text),
830 })
831 }
832}