Skip to main content

geop_ops_parts/
program.rs

1//! [`Program`]: an ordered list of operations that builds a [`Part`]; the
2//! edits it can undergo ([`ProgramEdit`]); and [`ProgramRunner`], which
3//! builds it incrementally.
4//!
5//! Editing lives here, not in any editor, so that every editor — the
6//! browser UI, a future desktop one, a script — changes programs the same
7//! way and is only a more convenient way of writing them.
8
9use std::collections::HashSet;
10
11use geop_core_math::{
12    geop_error::{GeopError, GeopResult, WithContext},
13    scalars::Scalar,
14    with_context,
15};
16use geop_core_part::{Part, validate_operation_id};
17use serde::{Deserialize, Serialize};
18
19use crate::operation::{Handle, PartOperation};
20
21/// One step of a [`Program`]: an operation with its arguments, and the id
22/// everything it creates is named after. Serializes as
23/// `{"id": "box", "operation": "extrude", "args": {...}}`.
24#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
25pub struct Step {
26    pub id: String,
27    #[serde(flatten)]
28    pub operation: PartOperation,
29}
30
31/// A recipe for building a [`Part`]: an ordered list of steps, each referring
32/// to what earlier ones built only by name. Those names come from step ids
33/// and sketch element ids, never from the internal ids a build happens to
34/// assign (see `geop_core_part`), so a program means the same thing every
35/// time it is run — including after a round trip through JSON.
36#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
37pub struct Program {
38    pub steps: Vec<Step>,
39}
40
41/// A change to a [`Program`]. Every edit of a program — whoever makes it —
42/// is one of these, applied by [`Program::update`].
43///
44/// Steps are addressed by id, not position, so an edit means the same thing
45/// however the steps around it have moved. Serializes as, e.g.,
46/// `{"edit": "update", "id": "box", "operation": "extrude", "args": {...}}`.
47#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
48#[serde(tag = "edit", rename_all = "snake_case")]
49pub enum ProgramEdit {
50    /// Insert `operation` as a new step at position `index` (the end, if it
51    /// is the number of steps), with the id `id` — or, if that is `None`, a
52    /// fresh one derived from the operation (see [`Program::fresh_id`]).
53    Insert {
54        index: usize,
55        #[serde(default)]
56        id: Option<String>,
57        #[serde(flatten)]
58        operation: PartOperation,
59    },
60    /// Give step `id` a new operation or new arguments, in place.
61    Update {
62        id: String,
63        #[serde(flatten)]
64        operation: PartOperation,
65    },
66    /// Remove step `id`. Steps that referred to what it built fail from then
67    /// on, until they are edited — the program is left as the user made it.
68    Remove { id: String },
69    /// Move step `id` to position `index` among the remaining steps.
70    Move { id: String, index: usize },
71    /// Replace the whole program, e.g. with one loaded from a file.
72    Replace { program: Program },
73}
74
75impl ProgramEdit {
76    /// What the edit does, in a few words — without the arguments, which
77    /// can be a whole sketch.
78    pub fn summary(&self) -> String {
79        match self {
80            ProgramEdit::Insert {
81                index, operation, ..
82            } => format!("insert a {} step at {index}", operation.kind()),
83            ProgramEdit::Update { id, operation } => {
84                format!("update step {id:?} to a {} step", operation.kind())
85            }
86            ProgramEdit::Remove { id } => format!("remove step {id:?}"),
87            ProgramEdit::Move { id, index } => format!("move step {id:?} to {index}"),
88            ProgramEdit::Replace { program } => {
89                format!(
90                    "replace the program by one of {} steps",
91                    program.steps.len()
92                )
93            }
94        }
95    }
96}
97
98impl Program {
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    /// Appends the step `id`: `operation` with its arguments.
104    pub fn push(&mut self, id: impl Into<String>, operation: impl Into<PartOperation>) {
105        self.steps.push(Step {
106            id: id.into(),
107            operation: operation.into(),
108        });
109    }
110
111    /// The position of step `id`.
112    pub fn index_of(&self, id: &str) -> GeopResult<usize> {
113        self.steps
114            .iter()
115            .position(|s| s.id == id)
116            .ok_or_else(|| GeopError::new(format!("program has no step {id:?}")))
117    }
118
119    /// An id no step has yet, for a new step running `operation`: its
120    /// label, lowercased, and the lowest number that makes it unique —
121    /// `sketch1`, `extrude2`.
122    pub fn fresh_id(&self, operation: &PartOperation) -> String {
123        let base = operation.label().to_lowercase().replace(' ', "_");
124        (1..)
125            .map(|n| format!("{base}{n}"))
126            .find(|id| self.steps.iter().all(|s| &s.id != id))
127            .expect("some number is free")
128    }
129
130    /// Checks that every step id is a valid operation id and unique: every
131    /// name a step creates is built from its id.
132    pub fn validate(&self) -> GeopResult<()> {
133        let mut ids = HashSet::new();
134        for step in &self.steps {
135            validate_operation_id(&step.id)?;
136            if !ids.insert(step.id.as_str()) {
137                return Err(GeopError::new(format!(
138                    "program has more than one step with id {:?}",
139                    step.id
140                )));
141            }
142        }
143        Ok(())
144    }
145
146    /// Applies `edit`, returning the id of the step it inserted, changed or
147    /// moved (`None` for a removal or a replacement). An edit that would
148    /// leave the program invalid — an unknown step, a position past the end,
149    /// a duplicate or malformed id — is rejected and changes nothing.
150    ///
151    /// This only changes the recipe; whether the steps still build is for
152    /// running it to say (see [`ProgramRunner`]).
153    pub fn update(&mut self, edit: ProgramEdit) -> GeopResult<Option<String>> {
154        let summary = edit.summary();
155        let ctx = with_context!("Program::update({summary})");
156        let mut next = self.clone();
157        let changed = match edit {
158            ProgramEdit::Insert {
159                index,
160                id,
161                operation,
162            } => {
163                if index > next.steps.len() {
164                    return Err(GeopError::new(format!(
165                        "cannot insert at {index}: the program has {} steps",
166                        next.steps.len()
167                    )))
168                    .with_context(ctx);
169                }
170                let id = id.unwrap_or_else(|| next.fresh_id(&operation));
171                next.steps.insert(
172                    index,
173                    Step {
174                        id: id.clone(),
175                        operation,
176                    },
177                );
178                Some(id)
179            }
180            ProgramEdit::Update { id, operation } => {
181                let index = next.index_of(&id).with_context(ctx)?;
182                next.steps[index].operation = operation;
183                Some(id)
184            }
185            ProgramEdit::Remove { id } => {
186                let index = next.index_of(&id).with_context(ctx)?;
187                next.steps.remove(index);
188                None
189            }
190            ProgramEdit::Move { id, index } => {
191                let from = next.index_of(&id).with_context(ctx)?;
192                let step = next.steps.remove(from);
193                if index > next.steps.len() {
194                    return Err(GeopError::new(format!(
195                        "cannot move to {index}: the program has {} other steps",
196                        next.steps.len()
197                    )))
198                    .with_context(ctx);
199                }
200                next.steps.insert(index, step);
201                Some(id)
202            }
203            ProgramEdit::Replace { program } => {
204                next = program;
205                None
206            }
207        };
208        next.validate().with_context(ctx)?;
209        *self = next;
210        Ok(changed)
211    }
212
213    /// Runs every step in order, starting from `part` (typically
214    /// [`Part::new`]), and returns the part the whole program builds — or
215    /// the first error any step raises, at which point the steps after it
216    /// never run.
217    ///
218    /// After each step, every entity of the part must have a name — an
219    /// operation that leaves one unnamed has broken the one guarantee a
220    /// program relies on.
221    pub fn apply<S: Scalar>(&self, part: Part<S>) -> GeopResult<Part<S>> {
222        self.validate()?;
223        let mut part = part;
224        for (index, step) in self.steps.iter().enumerate() {
225            part = run_step(part, index, step)?;
226        }
227        Ok(part)
228    }
229
230    /// The program as pretty-printed JSON: one step per object, every sketch
231    /// entity keyed by its id, so edits show up as small line diffs.
232    pub fn to_json(&self) -> GeopResult<String> {
233        serde_json::to_string_pretty(self)
234            .map_err(|e| GeopError::new(format!("serializing program: {e}")))
235    }
236
237    pub fn from_json(json: &str) -> GeopResult<Self> {
238        let program: Self = serde_json::from_str(json)
239            .map_err(|e| GeopError::new(format!("reading program: {e}")))?;
240        program.validate()?;
241        Ok(program)
242    }
243}
244
245/// Step `index` of a program applied to `part`, with every name checked.
246fn run_step<S: Scalar>(part: Part<S>, index: usize, step: &Step) -> GeopResult<Part<S>> {
247    let ctx = with_context!("program step {index} ({:?})", step.id);
248    let part = step.operation.apply(part, &step.id).with_context(ctx)?;
249    part.check_names().with_context(ctx)?;
250    Ok(part)
251}
252
253/// A handle (see [`Handle`]) of the step `step`.
254#[derive(Clone, Debug, PartialEq, Serialize)]
255pub struct StepHandle {
256    pub step: String,
257    #[serde(flatten)]
258    pub handle: Handle,
259}
260
261/// How one step of a run went.
262#[derive(Clone, Debug, PartialEq, Serialize)]
263pub struct StepResult {
264    pub id: String,
265    /// Why the step failed; `None` if it succeeded.
266    pub error: Option<String>,
267}
268
269/// Builds a program the way an editor needs it built: incrementally, and
270/// only as far as asked.
271///
272/// It keeps the part after every step it has run. Running again after an
273/// edit reuses the part after the longest unchanged prefix of steps, so
274/// changing the last step replays one step, not the whole history. And a
275/// run can stop early — while a step in the middle is being edited, only
276/// the steps up to it need to run, however long the rest of the program is.
277/// Parts past the stop are kept, not discarded, so moving the stop back
278/// again costs nothing.
279///
280/// A run stops at the first step that fails: the steps after it would only
281/// fail too, for want of what it should have built.
282pub struct ProgramRunner<S: Scalar> {
283    /// The steps the cache was built from.
284    steps: Vec<Step>,
285    /// `parts[i]`: the part after `steps[..i]`. A failed step leaves the
286    /// part as it was, so this stays one longer than `steps`.
287    parts: Vec<Part<S>>,
288    results: Vec<StepResult>,
289    /// How many steps the last run covers.
290    ran: usize,
291}
292
293impl<S: Scalar> ProgramRunner<S> {
294    pub fn new() -> Self {
295        Self {
296            steps: Vec::new(),
297            parts: vec![Part::new()],
298            results: Vec::new(),
299            ran: 0,
300        }
301    }
302
303    /// Runs the first `stop` steps of `program` — all of them if `None` —
304    /// reusing whatever the previous runs built that still applies. See
305    /// [`ProgramRunner::part`] and [`ProgramRunner::results`] for the
306    /// outcome.
307    pub fn run(&mut self, program: &Program, stop: Option<usize>) {
308        let common = self
309            .steps
310            .iter()
311            .zip(&program.steps)
312            .take_while(|(a, b)| a == b)
313            .count();
314        self.steps.truncate(common);
315        self.parts.truncate(common + 1);
316        self.results.truncate(common);
317
318        let target = stop.unwrap_or(program.steps.len()).min(program.steps.len());
319        let failed = |results: &[StepResult]| results.iter().any(|r| r.error.is_some());
320        while self.steps.len() < target && !failed(&self.results) {
321            let index = self.steps.len();
322            let step = &program.steps[index];
323            let before = self.parts.last().expect("parts is never empty");
324            let (part, error) = match run_step(before.clone(), index, step) {
325                Ok(part) => (part, None),
326                Err(e) => (before.clone(), Some(e.to_string())),
327            };
328            self.steps.push(step.clone());
329            self.parts.push(part);
330            self.results.push(StepResult {
331                id: step.id.clone(),
332                error,
333            });
334        }
335        // Up to the stop, or up to and including the first failure.
336        let first_failure = self.results.iter().position(|r| r.error.is_some());
337        self.ran = match first_failure {
338            Some(f) if f < target => f + 1,
339            _ => target.min(self.steps.len()),
340        };
341    }
342
343    /// The part the last run built.
344    pub fn part(&self) -> &Part<S> {
345        &self.parts[self.ran]
346    }
347
348    /// One result per step the last run covered.
349    pub fn results(&self) -> &[StepResult] {
350        &self.results[..self.ran]
351    }
352
353    /// Every handle of every step the last run built — all of them, of
354    /// every group: which to offer is an editor's choice. Each is placed
355    /// with the part as its step saw it.
356    pub fn handles(&self) -> GeopResult<Vec<StepHandle>> {
357        let mut handles = Vec::new();
358        for (i, (step, result)) in self.steps.iter().zip(self.results()).enumerate() {
359            if result.error.is_some() {
360                continue;
361            }
362            let ctx = with_context!("handles of step {i} ({:?})", step.id);
363            for handle in step.operation.handles(&self.parts[i]).with_context(ctx)? {
364                handles.push(StepHandle {
365                    step: step.id.clone(),
366                    handle,
367                });
368            }
369        }
370        Ok(handles)
371    }
372}
373
374impl<S: Scalar> Default for ProgramRunner<S> {
375    fn default() -> Self {
376        Self::new()
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use geop_core_math::scalars::ScalInF64 as S;
383    use geop_core_part::PartDescription;
384
385    use super::*;
386    use crate::{ExtrudeArgs, examples::box_with_drill_hole};
387
388    fn extrude(sketch: &str, distance: f64) -> PartOperation {
389        ExtrudeArgs {
390            sketch: sketch.into(),
391            distance,
392            symmetric: false,
393            combine: crate::Combine::NewBody,
394        }
395        .into()
396    }
397
398    /// Every kind of edit, addressed by id, and each rejected when it would
399    /// leave the program invalid — without changing anything.
400    #[test]
401    fn edits_change_the_program_by_id() {
402        let mut program = box_with_drill_hole();
403        let len = program.steps.len();
404
405        let id = program
406            .update(ProgramEdit::Insert {
407                index: len,
408                id: None,
409                operation: extrude("outline", 2.0),
410            })
411            .unwrap();
412        assert_eq!(id.as_deref(), Some("extrude1"));
413        assert_eq!(program.steps[len].id, "extrude1");
414
415        program
416            .update(ProgramEdit::Update {
417                id: "extrude1".into(),
418                operation: extrude("outline", 3.0),
419            })
420            .unwrap();
421        assert_eq!(program.steps[len].operation, extrude("outline", 3.0));
422
423        program
424            .update(ProgramEdit::Move {
425                id: "extrude1".into(),
426                index: 0,
427            })
428            .unwrap();
429        assert_eq!(program.steps[0].id, "extrude1");
430
431        program
432            .update(ProgramEdit::Remove {
433                id: "extrude1".into(),
434            })
435            .unwrap();
436        assert_eq!(program, box_with_drill_hole());
437
438        let before = program.clone();
439        for bad in [
440            ProgramEdit::Insert {
441                index: len + 1,
442                id: None,
443                operation: extrude("outline", 1.0),
444            },
445            ProgramEdit::Insert {
446                index: 0,
447                id: Some("box".into()),
448                operation: extrude("outline", 1.0),
449            },
450            ProgramEdit::Insert {
451                index: 0,
452                id: Some("not an id".into()),
453                operation: extrude("outline", 1.0),
454            },
455            ProgramEdit::Remove { id: "nope".into() },
456            ProgramEdit::Move {
457                id: "box".into(),
458                index: len,
459            },
460        ] {
461            assert!(program.update(bad.clone()).is_err(), "{bad:?}");
462            assert_eq!(program, before, "a rejected {bad:?} changed the program");
463        }
464    }
465
466    /// An edit serializes as one flat JSON object, as an editor sends it.
467    #[test]
468    fn edits_read_from_json() {
469        let edit: ProgramEdit = serde_json::from_str(
470            r#"{"edit": "insert", "index": 0, "operation": "extrude",
471                "args": {"sketch": "outline", "distance": 2.0}}"#,
472        )
473        .unwrap();
474        assert_eq!(
475            edit,
476            ProgramEdit::Insert {
477                index: 0,
478                id: None,
479                operation: extrude("outline", 2.0),
480            }
481        );
482    }
483
484    /// A runner builds what [`Program::apply`] builds; stopping early builds
485    /// just the steps before the stop; and a run after an edit starts from
486    /// the last unchanged step.
487    #[test]
488    fn runner_stops_early_and_reuses_the_unchanged_prefix() {
489        let program = box_with_drill_hole();
490        let describe = |part: &Part<S>| PartDescription::of(part).unwrap();
491        let mut runner = ProgramRunner::<S>::new();
492
493        runner.run(&program, None);
494        assert!(runner.results().iter().all(|r| r.error.is_none()));
495        assert_eq!(
496            describe(runner.part()),
497            describe(&program.apply(Part::new()).unwrap())
498        );
499
500        // Back in time: only the box.
501        runner.run(&program, Some(2));
502        assert_eq!(runner.results().len(), 2);
503        let description = describe(runner.part());
504        assert_eq!(
505            description.solids.keys().collect::<Vec<_>>(),
506            ["extrude(box)"]
507        );
508
509        // An edit to the hole keeps the box's part: the first two steps are
510        // served from the cache, which has to hold exactly what they built.
511        let mut edited = program.clone();
512        edited
513            .update(ProgramEdit::Update {
514                id: "hole".into(),
515                operation: extrude("hole_sketch", -0.25),
516            })
517            .unwrap();
518        runner.run(&edited, None);
519        assert!(runner.results().iter().all(|r| r.error.is_none()));
520        assert_eq!(
521            describe(runner.part()),
522            describe(&edited.apply(Part::new()).unwrap())
523        );
524    }
525
526    /// Every step's handles, placed where the part as that step saw it puts
527    /// them: the box's distance at its top, the hole's at its bottom, and a
528    /// handle for every sketch point.
529    #[test]
530    fn runner_provides_every_handle() {
531        use crate::operation::{HandleGroup, HandleMotion};
532        let mut runner = ProgramRunner::<S>::new();
533        runner.run(&box_with_drill_hole(), None);
534        let handles = runner.handles().unwrap();
535        let feature: Vec<&StepHandle> = handles
536            .iter()
537            .filter(|h| h.handle.group == HandleGroup::Feature)
538            .collect();
539        assert_eq!(feature.len(), 2);
540        let close = |a: [f64; 3], b: [f64; 3]| (0..3).all(|k| (a[k] - b[k]).abs() < 1e-9);
541        let (boxed, hole) = (feature[0], feature[1]);
542        assert_eq!(boxed.step, "box");
543        assert!(close(boxed.handle.position, [1.0, 1.0, 1.0]), "{boxed:?}");
544        assert_eq!(hole.step, "hole");
545        assert!(close(hole.handle.position, [1.0, 1.0, 0.5]), "{hole:?}");
546        let HandleMotion::Linear {
547            direction,
548            arg,
549            value,
550            scale,
551        } = &hole.handle.motion
552        else {
553            panic!("{hole:?}")
554        };
555        assert!(close(*direction, [0.0, 0.0, 1.0]));
556        assert_eq!(arg, &["distance"]);
557        assert_eq!((*value, *scale), (-0.5, 1.0));
558        // 4 corners of the outline, the circle's center.
559        let sketch = handles.len() - feature.len();
560        assert_eq!(sketch, 5);
561        let json = serde_json::to_value(&handles[0]).unwrap();
562        assert_eq!(json["step"], "outline");
563        assert_eq!(json["motion"], "planar");
564    }
565
566    /// A step that fails ends the run there, reported by id; the part is
567    /// what the steps before it built.
568    #[test]
569    fn runner_reports_the_failing_step() {
570        let mut program = box_with_drill_hole();
571        program
572            .update(ProgramEdit::Update {
573                id: "hole".into(),
574                operation: ExtrudeArgs {
575                    sketch: "hole_sketch".into(),
576                    distance: -0.5,
577                    symmetric: false,
578                    combine: crate::Combine::Difference {
579                        target: "extrude(nothing)".into(),
580                    },
581                }
582                .into(),
583            })
584            .unwrap();
585        let mut runner = ProgramRunner::<S>::new();
586        runner.run(&program, None);
587        let last = runner.results().last().unwrap();
588        assert_eq!(last.id, "hole");
589        assert!(last.error.as_deref().unwrap().contains("extrude(nothing)"));
590        assert!(runner.part().solid_id("extrude(box)").is_ok());
591    }
592}