1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
53pub struct Route;
54
55#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
56pub struct RouteArgs {
57 #[serde(default)]
62 pub through: Vec<Selected>,
63 pub wires: Vec<Wire>,
65 pub fill: f64,
68 pub bend_factor: f64,
70 #[serde(default)]
72 pub service_loop: f64,
73}
74
75#[derive(Clone, Debug, Default)]
77pub struct RouteSession {
78 pub selected: Option<usize>,
79}
80
81pub type RouteEditing = FormState<RouteSession>;
83
84struct Plan {
87 diameter: f64,
88 min_bend_radius: f64,
89 path: RoutePath,
90 measure: Measure,
91}
92
93impl RouteArgs {
94 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 fn plan(&self, waypoints: &[Waypoint]) -> GeopResult<Plan> {
103 let diameter = bundle_diameter(&self.wires, self.fill)?;
104 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 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
154fn 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
178fn 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
192const SAMPLES_PER_CURVE: usize = 16;
194
195fn 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 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
225fn 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#[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
265fn 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#[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
292fn 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 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 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 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
528fn 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}