Skip to main content

geop_ops_harness/
operation.rs

1//! [`Route`]: lay a bundle of wires between connectors, through clips.
2
3use geop_core_editor::{
4    edit_form::{Edit, EditForm, FormState},
5    editable::{Context, OperationGroup, OperationInfo, OperationTier},
6    editing::{Action, Choice, ListItem, Number, Shape, Style, Tone, Unit, Value, Visual},
7    part_operation,
8    part_operation::PartOperation,
9};
10use geop_core_math::{
11    geop_error::{GeopError, GeopResult, WithContext},
12    scalars::Scalar,
13    vector::Vector3,
14    with_context,
15};
16use geop_core_program::{part::StepContext, reader::Reader};
17use geop_lib_sweep::{Control, SweepLoop, circle_at, sweep_along};
18use geop_ops_model::Profile;
19use geop_ops_model::selection::Selected;
20use geop_ops_model::{
21    CutWire, ModelOperation, NamedModel, Namer, SolidNotes, build_model, frame_along, solid_notes,
22};
23use serde::{Deserialize, Serialize};
24
25use crate::{
26    cable::{Cable, Cables, cable, cables},
27    route::{Measure, RoutePath, Waypoint},
28    wire::{GAUGES, Wire, WireSize, bundle_diameter},
29};
30
31/// Routes a bundle of wires through points picked in order — from the
32/// connector it starts at, through clips, to the connector it ends at —
33/// as a tangent-continuous chain of lines and arcs (see [`crate::route`]),
34/// and sweeps the bundle along it into a solid named `route(R)` for the
35/// operation `R`. A route that bends tighter than the bundle may is
36/// refused, naming the waypoints each bend lies between, its radius and
37/// the radius allowed.
38///
39/// What the bundle is cut from is recorded as the cable `route(R)` of the
40/// part's [`cables`] (see [`Cable`]): every wire with its cut length, the
41/// route's length and a service loop at each end.
42///
43/// The points may be of parts placed in the part: the route follows them
44/// wherever their mates put them, rerouted whenever the program runs.
45///
46/// The solid is named like any path sweep (see
47/// [`geop_lib_sweep::sweep_along`]): the profile is a
48/// circle of four arcs `c0`–`c3` meeting at `p0`–`p3`, the path's curves
49/// and joints are named as [`RoutePath`] says — `route(R,c0,a1)` is a
50/// quarter of the bundle's skin along the first arc after the second
51/// waypoint — and its ends are `route(R,start)` and `route(R,end)`.
52#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
53pub struct Route;
54
55#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
56pub struct RouteArgs {
57    /// What the route runs through, in order, of the part or of parts
58    /// placed in it: points, coordinate systems (along their `z` axis: out
59    /// of a connector at the start, into one at the end) and circular edges
60    /// (through their centre, along their axis) — see [`Waypoint::of`].
61    #[serde(default)]
62    pub through: Vec<Selected>,
63    /// The wires bundled.
64    pub wires: Vec<Wire>,
65    /// The fraction of the bundle's section its wires fill (see
66    /// [`bundle_diameter`]).
67    pub fill: f64,
68    /// The smallest radius the bundle may bend with, in bundle diameters.
69    pub bend_factor: f64,
70    /// How much longer than the route each wire is cut at each end.
71    #[serde(default)]
72    pub service_loop: f64,
73}
74
75/// What editing a route keeps: the wire selected.
76#[derive(Clone, Debug, Default)]
77pub struct RouteSession {
78    pub selected: Option<usize>,
79}
80
81/// What a route step keeps while it is edited.
82pub type RouteEditing = FormState<RouteSession>;
83
84/// What a step of `args` routes: the bundle's diameter, the smallest
85/// radius it may bend with, and the path through the points it picked.
86struct Plan {
87    diameter: f64,
88    min_bend_radius: f64,
89    path: RoutePath,
90    measure: Measure,
91}
92
93impl RouteArgs {
94    /// The points it runs through, in the part `reader` reads.
95    fn waypoints(&self, reader: &Reader) -> GeopResult<Vec<Waypoint>> {
96        (self.through.iter().enumerate())
97            .map(|(i, entity)| Waypoint::of(reader, entity, i))
98            .collect()
99    }
100
101    /// The route through `waypoints`, the points it runs through.
102    fn plan(&self, waypoints: &[Waypoint]) -> GeopResult<Plan> {
103        let diameter = bundle_diameter(&self.wires, self.fill)?;
104        // Any tighter, and the bundle would fold into itself.
105        if !(self.bend_factor > 0.5 && self.bend_factor.is_finite()) {
106            return Err(GeopError::new(format!(
107                "route: a bend factor of {}: the bundle cannot bend tighter than its own radius, half its diameter",
108                self.bend_factor
109            )));
110        }
111        if !(self.service_loop >= 0.0 && self.service_loop.is_finite()) {
112            return Err(GeopError::new(format!(
113                "route: a service loop of {}: it is a length, zero or more",
114                self.service_loop
115            )));
116        }
117        let path = RoutePath::through(waypoints)?;
118        let measure = path.measure()?;
119        Ok(Plan {
120            diameter,
121            min_bend_radius: self.bend_factor * diameter,
122            path,
123            measure,
124        })
125    }
126}
127
128impl Plan {
129    /// Refuses a route bending tighter than the bundle may, saying where.
130    fn check_bends(&self, bend_factor: f64) -> GeopResult<()> {
131        let tight = self.measure.too_tight(self.min_bend_radius);
132        if tight.is_empty() {
133            return Ok(());
134        }
135        let bends: Vec<String> = tight
136            .iter()
137            .map(|b| {
138                format!(
139                    "with radius {:.3} {}",
140                    b.radius.to_f64(),
141                    self.path.places[b.curve]
142                )
143            })
144            .collect();
145        Err(GeopError::new(format!(
146            "route: the bundle, {:.3} across, may bend no tighter than radius {:.3} ({bend_factor} × its diameter), but it bends {}: move the points apart, or give the route room to turn",
147            self.diameter,
148            self.min_bend_radius,
149            bends.join(", and ")
150        )))
151    }
152}
153
154/// The gauges a wire's size is chosen from, by `awg:<gauge>`, and a
155/// diameter of its own.
156fn size_choices(size: &WireSize) -> Vec<Choice> {
157    let mut gauges: Vec<i32> = (10..=30).step_by(2).collect();
158    if let Some(g) = size.gauge()
159        && !gauges.contains(&g)
160    {
161        gauges.push(g);
162        gauges.sort();
163    }
164    gauges
165        .into_iter()
166        .map(|g| Choice::new(format!("awg:{g}"), format!("AWG {g}")))
167        .chain([Choice::new("diameter", "diameter")])
168        .collect()
169}
170
171fn size_value(size: &WireSize) -> String {
172    match size {
173        WireSize::Awg { gauge, .. } => format!("awg:{gauge}"),
174        WireSize::Diameter(_) => "diameter".into(),
175    }
176}
177
178/// How a wire reads in the list: its size and colour — and, built, the
179/// length to cut it to.
180fn wire_detail(wire: &Wire, cut: Option<f64>) -> String {
181    let size = match (wire.size, wire.size.diameter()) {
182        (WireSize::Awg { gauge, .. }, Ok(d)) => format!("AWG {gauge}, {d:.2} across"),
183        (WireSize::Diameter(_), Ok(d)) => format!("{d:.2} across"),
184        (_, Err(e)) => e.root_message().to_string(),
185    };
186    match cut {
187        Some(cut) => format!("{size} · {} · cut {cut:.1}", wire.colour),
188        None => format!("{size} · {}", wire.colour),
189    }
190}
191
192/// Samples along each curve of the route, to draw it by.
193const SAMPLES_PER_CURVE: usize = 16;
194
195/// The step `R`'s bundle swept along its route through `waypoints`, the
196/// points it picked, into the solid `route(R)` (see [`Route`]).
197fn sweep(
198    model: &mut NamedModel,
199    step: &str,
200    args: &RouteArgs,
201    waypoints: &[Waypoint],
202) -> GeopResult<()> {
203    let namer = Namer::new("route", step)?;
204    let plan = args.plan(waypoints)?;
205    plan.check_bends(args.bend_factor)?;
206    // The bundle's section, square to the route where it starts.
207    let chain = &plan.path.chain;
208    let start = chain.joints()?[0];
209    let plane = frame_along(start, &chain.curves[0].tangent(Scalar::ZERO)?)?;
210    let section = SweepLoop::plain(Profile::closed(circle_at(0.0, 0.0, plan.diameter / 2.0)?));
211    let name = namer.root();
212    sweep_along(
213        model,
214        &namer,
215        Some(&name),
216        chain,
217        &plane,
218        &[section],
219        &Control::default(),
220        None,
221    )?;
222    Ok(())
223}
224
225/// The cable the step `R` lays, `route(R)` (see [`Route`]), through
226/// `waypoints`, the points it picked.
227fn cable_through(args: &RouteArgs, waypoints: &[Waypoint]) -> GeopResult<Cable> {
228    let plan = args.plan(waypoints)?;
229    let length = plan.measure.length;
230    let extra = Scalar::from_f64(2.0 * args.service_loop);
231    let wires = (args.wires.iter())
232        .map(|wire| {
233            Ok(CutWire {
234                name: wire.name.clone(),
235                colour: wire.colour.clone(),
236                diameter: wire.size.diameter()?,
237                gauge: wire.size.gauge(),
238                cut_length: length.add(extra),
239            })
240        })
241        .collect::<GeopResult<Vec<_>>>()?;
242    Ok(Cable {
243        length,
244        diameter: plan.diameter,
245        min_bend_radius: plan.min_bend_radius,
246        tightest_bend: plan.measure.tightest().map(|b| b.radius),
247        wires,
248    })
249}
250
251/// Builds the route of `args` through `waypoints` as the step `step` into
252/// `model`, and the cable it lays: a route in a model on its own, in any
253/// scalar, as a test lays one.
254#[cfg(test)]
255pub(crate) fn lay(
256    model: &mut NamedModel,
257    step: &str,
258    args: &RouteArgs,
259    waypoints: &[Waypoint],
260) -> GeopResult<Cable> {
261    sweep(model, step, args, waypoints)?;
262    cable_through(args, waypoints)
263}
264
265/// Records the cable `route(R)` of the step `R`, through its points in the
266/// part `reader` reads.
267fn record(reader: &Reader, (step, args): &(String, RouteArgs)) -> GeopResult<Cables> {
268    let ctx = with_context!("the cable of route({step})");
269    let namer = Namer::new("route", step)?;
270    let mut cables: Cables = reader.previous()?;
271    let waypoints = args.waypoints(reader).with_context(ctx)?;
272    let cable = cable_through(args, &waypoints).with_context(ctx)?;
273    cables.add(namer.root(), cable).with_context(ctx)?;
274    Ok(cables)
275}
276
277/// Notes the bundle `route(R)` of the step `R`: only drawn, its cable's
278/// wires ordered in its place.
279// A generator's arguments are a type of their own: the step's id, a `String`.
280#[allow(clippy::ptr_arg)]
281fn note(reader: &Reader, step: &String) -> GeopResult<SolidNotes> {
282    let bundle = Namer::new("route", step)?.root();
283    let mut notes: SolidNotes = reader.previous()?;
284    let cable = cable(reader, &bundle)
285        .ok_or_else(|| GeopError::new(format!("the part has no cable named {bundle:?}")))?;
286    let note = notes.note_mut(bundle);
287    note.drawn_only = true;
288    note.wires = cable.wires.clone();
289    Ok(notes)
290}
291
292/// The points to run through, picked in order; the wires, each selected
293/// to set its size and colour; the bundle's fill, bend factor and service
294/// loop; what was built — and the route drawn, its points numbered and its
295/// bends too tight marked.
296fn form<'a>(
297    context: &'a Context<'a>,
298    args: &RouteArgs,
299    session: &RouteSession,
300) -> EditForm<'a, RouteArgs, RouteSession> {
301    let before = &context.before;
302    let mut f = EditForm::<RouteArgs, RouteSession>::default();
303    f.pick(
304        "through",
305        "through",
306        before,
307        &args.through,
308        true,
309        |edit, picked| edit.args.through = picked,
310    );
311    f.form.dialog.text(
312            "through_hint",
313            "Pick the connector the route starts at, the clips it runs through and the connector it ends at, in order. A coordinate system's z axis is the way the cable leaves it; a circular edge is passed through along its axis.",
314            Tone::Hint,
315        );
316
317    let cable = (context.built.as_ref())
318        .and_then(|built| cable(built, &format!("route({})", context.step)));
319    f.form.dialog.heading("wires_heading", "Wires");
320    let items = args
321        .wires
322        .iter()
323        .enumerate()
324        .map(|(i, wire)| {
325            let key = format!("wire:{i}");
326            f.on(key.clone(), move |edit, value| match value {
327                Value::Press => {
328                    edit.session.selected = (edit.session.selected != Some(i)).then_some(i);
329                }
330                Value::Remove if i < edit.args.wires.len() => {
331                    edit.args.wires.remove(i);
332                    edit.session.selected = None;
333                }
334                Value::Text(name) => {
335                    if let Some(wire) = edit.args.wires.get_mut(i) {
336                        wire.name = name;
337                    }
338                }
339                _ => {}
340            });
341            let cut = cable
342                .and_then(|c| c.wires.get(i))
343                .map(|w| w.cut_length.to_f64());
344            let mut item = ListItem::new(key, wire.name.clone());
345            item.detail = Some(wire_detail(wire, cut));
346            item.tone = match wire.size.diameter() {
347                Ok(_) => Tone::Normal,
348                Err(_) => Tone::Error,
349            };
350            item.selected = session.selected == Some(i);
351            item.removable = true;
352            item.text = Some(wire.name.clone());
353            item
354        })
355        .collect();
356    f.form.dialog.list("wires", items, "None yet: add one.");
357    f.actions(
358        "add_wire",
359        vec![Action::new("add", "Add wire").title("Add a wire to the bundle, and select it.")],
360        |edit, _| {
361            let n = edit.args.wires.len();
362            edit.args.wires.push(Wire::new(format!("w{}", n + 1)));
363            edit.session.selected = Some(n);
364        },
365    );
366    if let Some((i, wire)) = session
367        .selected
368        .and_then(|i| args.wires.get(i).map(|w| (i, w)))
369    {
370        f.select(
371            "wire_size",
372            format!("{} size", wire.name),
373            size_value(&wire.size),
374            size_choices(&wire.size),
375            false,
376            move |Edit { args, .. }, choice| {
377                let Some(wire) = args.wires.get_mut(i) else {
378                    return;
379                };
380                let insulation = match wire.size {
381                    WireSize::Awg { insulation, .. } => insulation,
382                    WireSize::Diameter(_) => 0.3,
383                };
384                if let Some(gauge) = choice
385                    .strip_prefix("awg:")
386                    .and_then(|g| g.parse().ok())
387                    .filter(|g| GAUGES.contains(g))
388                {
389                    wire.size = WireSize::Awg { gauge, insulation };
390                } else if choice == "diameter"
391                    && let Ok(d) = wire.size.diameter()
392                {
393                    wire.size = WireSize::Diameter(d);
394                }
395            },
396        );
397        match wire.size {
398            WireSize::Awg { insulation, .. } => {
399                f.number(
400                    "wire_insulation",
401                    Number::new("insulation", insulation, Unit::Length).range(0.0, 1.0),
402                    move |Edit { args, .. }, v| {
403                        if let Some(WireSize::Awg { insulation, .. }) =
404                            args.wires.get_mut(i).map(|w| &mut w.size)
405                        {
406                            *insulation = v;
407                        }
408                    },
409                );
410            }
411            WireSize::Diameter(d) => {
412                f.number(
413                    "wire_diameter",
414                    Number::new("diameter", d, Unit::Length).range(0.1, 20.0),
415                    move |Edit { args, .. }, v| {
416                        if let Some(wire) = args.wires.get_mut(i) {
417                            wire.size = WireSize::Diameter(v);
418                        }
419                    },
420                );
421            }
422        }
423        f.color(
424            "wire_colour",
425            "colour",
426            wire.colour.clone(),
427            move |Edit { args, .. }, c| {
428                if let Some(wire) = args.wires.get_mut(i) {
429                    wire.colour = c.to_string();
430                }
431            },
432        );
433    }
434
435    f.form.dialog.heading("bundle_heading", "Bundle");
436    f.number(
437        "fill",
438        Number::new("fill factor", args.fill, Unit::Fraction).range(0.3, 1.0),
439        |Edit { args, .. }, v| args.fill = v,
440    );
441    f.number(
442        "bend_factor",
443        Number::new("min bend radius / diameter", args.bend_factor, Unit::Count).range(1.0, 20.0),
444        |Edit { args, .. }, v| args.bend_factor = v,
445    );
446    f.number(
447        "service_loop",
448        Number::new("service loop per end", args.service_loop, Unit::Length).range(0.0, 200.0),
449        |Edit { args, .. }, v| args.service_loop = v,
450    );
451    if let Some(cable) = cable {
452        let tightest = match cable.tightest_bend {
453            Some(r) => format!("tightest bend radius {:.2}", r.to_f64()),
454            None => "straight".into(),
455        };
456        f.form.dialog.text(
457            "report",
458            format!(
459                "Route {:.1} long · bundle {:.2} across · {tightest}, at least {:.2} allowed",
460                cable.length.to_f64(),
461                cable.diameter,
462                cable.min_bend_radius
463            ),
464            Tone::Success,
465        );
466    }
467    draw(&mut f, before, args);
468    f
469}
470
471part_operation!(Route(RouteArgs));
472
473impl PartOperation for Route {
474    const KIND: &'static str = "route";
475    const INFO: OperationInfo = OperationInfo::new(
476        "Route",
477        "Route a bundle of wires from a connector through clips to another connector, its bends checked and every wire's cut length reported.",
478        OperationGroup::Assembly,
479        OperationTier::Small,
480    )
481    .experimental()
482    .icon(crate::icons::ROUTE);
483    type Args = RouteArgs;
484    type Session = RouteSession;
485
486    /// Nothing picked yet; one hookup wire, filling three quarters of the
487    /// bundle, bent no tighter than five diameters.
488    fn new_args(_: &Context) -> RouteArgs {
489        RouteArgs {
490            through: Vec::new(),
491            wires: vec![Wire::new("w1")],
492            fill: 0.75,
493            bend_factor: 5.0,
494            service_loop: 0.0,
495        }
496    }
497
498    /// Sweeps the bundle, then records its cable, and notes the bundle.
499    fn run(ctx: &mut StepContext, args: &RouteArgs) -> GeopResult<()> {
500        let step = ctx.step();
501        build_model::<Self>(ctx, args)?;
502        ctx.produce(&cables(), &(step.to_string(), args.clone()), record)?;
503        ctx.produce(&solid_notes(), &step.to_string(), note)
504    }
505
506    fn form<'a>(
507        context: &'a Context<'a>,
508        args: &RouteArgs,
509        editing: &RouteEditing,
510    ) -> EditForm<'a, RouteArgs, RouteSession> {
511        form(context, args, &editing.session)
512    }
513}
514
515impl ModelOperation for Route {
516    /// The points are resolved in the part as the steps before left it.
517    fn build(
518        model: &mut NamedModel,
519        reader: &Reader,
520        step: &str,
521        args: &RouteArgs,
522    ) -> GeopResult<()> {
523        let waypoints = args.waypoints(reader)?;
524        sweep(model, step, args, &waypoints)
525    }
526}
527
528/// The route drawn: its points numbered, as what it says about them counts
529/// them, and its centre line, the bends too tight marked as failed.
530fn draw(f: &mut EditForm<'_, RouteArgs, RouteSession>, reader: &Reader, args: &RouteArgs) {
531    let waypoints: Vec<Option<Waypoint>> = (args.through.iter().enumerate())
532        .map(|(i, entity)| Waypoint::of(reader, entity, i).ok())
533        .collect();
534    for (i, waypoint) in waypoints.iter().enumerate() {
535        let Some(waypoint) = waypoint else { continue };
536        f.form.visuals.push(Visual::new(
537            format!("point:{i}"),
538            Shape::Label {
539                at: waypoint.point,
540                text: (i + 1).to_string(),
541                offset: Vector3::from_array([Scalar::ZERO, Scalar::ZERO, Scalar::from_f64(2.0)]),
542            },
543            Style::Reference,
544        ));
545    }
546    let Some(waypoints) = waypoints.into_iter().collect::<Option<Vec<_>>>() else {
547        return;
548    };
549    let Ok(plan) = args.plan(&waypoints) else {
550        return;
551    };
552    let tight: Vec<usize> = plan
553        .measure
554        .too_tight(plan.min_bend_radius)
555        .iter()
556        .map(|b| b.curve)
557        .collect();
558    for (k, curve) in plan.path.chain.curves.iter().enumerate() {
559        let points = (0..=SAMPLES_PER_CURVE)
560            .map(|j| curve.evaluate(Scalar::from_ratio(j as i64, SAMPLES_PER_CURVE as i64)?))
561            .collect::<GeopResult<Vec<_>>>();
562        let Ok(points) = points else { continue };
563        let style = if tight.contains(&k) {
564            Style::Failed
565        } else {
566            Style::Guide
567        };
568        f.form.visuals.push(Visual::new(
569            format!("route:{k}"),
570            Shape::Polyline { points },
571            style,
572        ));
573    }
574}