1use ogeom_algo::{
23 Built, History, edge_vertices, find_plane, make_edge_between, make_vertex, make_wire,
24};
25use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
26use ogeom_geom::Curve3d as _;
27use ogeom_geom::{CircleCurve, Curve, LineCurve, PlanarCurve};
28use ogeom_math::{Circle, Frame, Point, Point2, Vector2};
29use ogeom_topo::{Filter, Model, Shape, ShapeType, explore};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum Join {
35 Arc,
37 #[default]
41 Intersection,
42}
43
44#[derive(Debug, Clone)]
46enum Piece {
47 Seg {
48 from: Point2,
49 to: Point2,
50 },
51 Arc {
54 centre: Point2,
55 radius: f64,
56 start: f64,
57 end: f64,
58 },
59}
60
61impl Piece {
62 fn start_point(&self) -> Point2 {
63 match self {
64 Self::Seg { from, .. } => *from,
65 Self::Arc {
66 centre,
67 radius,
68 start,
69 ..
70 } => at_angle(*centre, *radius, *start),
71 }
72 }
73
74 fn end_point(&self) -> Point2 {
75 match self {
76 Self::Seg { to, .. } => *to,
77 Self::Arc {
78 centre,
79 radius,
80 end,
81 ..
82 } => at_angle(*centre, *radius, *end),
83 }
84 }
85
86 fn tangent(&self, at_end: bool) -> Vector2 {
88 match self {
89 Self::Seg { from, to } => {
90 let d = *to - *from;
91 d / d.magnitude()
92 }
93 Self::Arc {
94 centre,
95 radius,
96 start,
97 end,
98 } => {
99 let a = if at_end { *end } else { *start };
100 let radial = (at_angle(*centre, *radius, a) - *centre) / *radius;
101 let ccw = end > start;
102 if ccw {
103 Vector2::new(-radial.y, radial.x)
104 } else {
105 Vector2::new(radial.y, -radial.x)
106 }
107 }
108 }
109 }
110}
111
112fn at_angle(centre: Point2, radius: f64, angle: f64) -> Point2 {
113 Point2::new(
114 radius.mul_add(angle.cos(), centre.x),
115 radius.mul_add(angle.sin(), centre.y),
116 )
117}
118
119pub fn offset_wire(
136 model: &mut Model,
137 wire: &Shape,
138 offset: f64,
139 join: Join,
140 tol: Tolerances,
141) -> OgeomResult<Built> {
142 if !offset.is_finite() || offset.abs() <= tol.confusion() {
143 ogeom_bail!(Construction, "an offset of {offset} moves nothing");
144 }
145 if model.kind_of(wire)? != ShapeType::Wire {
146 ogeom_bail!(Construction, "offsetting starts from a wire");
147 }
148 let open = !ogeom_algo::is_wire_closed(model, wire, tol)?;
149 let plane = match find_plane(model, wire, tol)? {
150 Some(plane) => plane,
151 None if open => {
152 let ends = explore(model, wire, Filter::OfType(ShapeType::Vertex))?;
155 let mut points = Vec::new();
156 for v in &ends {
157 if let Some(data) = model.node(v).and_then(|n| n.data().as_vertex()) {
158 points.push(v.transform(model.datums())?.apply(data.point));
159 }
160 }
161 if points.len() < 2 {
162 ogeom_bail!(Construction, "the wire is not planar");
163 }
164 let along = ogeom_math::Direction::new(points[1] - points[0], tol)?;
165 let reference = if along.vector().cross(ogeom_math::Vector::Z).magnitude() > 0.5 {
166 ogeom_math::Direction::Z
167 } else {
168 ogeom_math::Direction::X
169 };
170 let normal = ogeom_math::Direction::new(along.vector().cross(reference.vector()), tol)?;
171 ogeom_math::Plane::through(points[0], normal)
172 }
173 None => ogeom_bail!(Construction, "the wire is not planar"),
174 };
175 let frame = plane.frame();
176 let flat = |p: Point| {
177 let local = frame.to_local(p);
178 Point2::new(local.x, local.y)
179 };
180
181 let edges = explore(model, wire, Filter::OfType(ShapeType::Edge))?;
183 let mut pieces: Vec<Piece> = Vec::with_capacity(edges.len());
184 for edge in &edges {
185 let Some((curve, range)) = ogeom_algo::placed_edge_curve(model, edge, tol)? else {
186 ogeom_bail!(Construction, "an edge has no curve to offset");
187 };
188 let Some((sv, ev)) = edge_vertices(model, edge)? else {
189 ogeom_bail!(Construction, "an edge has no bounding vertices");
190 };
191 let position = |v: &Shape| -> OgeomResult<Point> {
192 let Some(node) = model.node(v) else {
193 ogeom_bail!(Dangling, "vertex is not in this model");
194 };
195 let Some(data) = node.data().as_vertex() else {
196 ogeom_bail!(Construction, "vertex node holds no point");
197 };
198 Ok(v.transform(model.datums())?.apply(data.point))
199 };
200 let from = flat(position(&sv)?);
201 let to = flat(position(&ev)?);
202 match &curve {
203 Curve::Line(_) => pieces.push(Piece::Seg { from, to }),
204 Curve::Circle(c) => {
205 let centre = flat(c.circle().centre());
206 let radius = c.circle().radius();
207 let mid = flat(curve.point_at(f64::midpoint(range.0, range.1), tol)?);
208 let ccw = (mid - from).cross(to - mid) > 0.0;
211 let a0 = (from - centre).y.atan2((from - centre).x);
212 let mut a1 = (to - centre).y.atan2((to - centre).x);
213 let tau = core::f64::consts::TAU;
214 if ccw {
215 while a1 <= a0 + tol.parametric() {
216 a1 += tau;
217 }
218 } else {
219 while a1 >= a0 - tol.parametric() {
220 a1 -= tau;
221 }
222 }
223 pieces.push(Piece::Arc {
224 centre,
225 radius,
226 start: a0,
227 end: a1,
228 });
229 }
230 _ => ogeom_bail!(
231 Construction,
232 "offsetting an edge that is neither straight nor circular \
233 needs the offset-curve machinery; see docs/CAPABILITIES.md, offset.wire-offset"
234 ),
235 }
236 }
237
238 let source_count = pieces.len();
243 if open {
244 for piece in pieces.clone().iter().rev() {
245 pieces.push(match piece {
246 Piece::Seg { from, to } => Piece::Seg {
247 from: *to,
248 to: *from,
249 },
250 Piece::Arc {
251 centre,
252 radius,
253 start,
254 end,
255 } => Piece::Arc {
256 centre: *centre,
257 radius: *radius,
258 start: *end,
259 end: *start,
260 },
261 });
262 }
263 }
264
265 let offset = if open { offset.abs() } else { offset };
270 let winding = if open {
271 1.0
272 } else {
273 let mut area = 0.0;
274 let mut samples: Vec<Point2> = Vec::new();
275 for piece in &pieces {
276 match piece {
277 Piece::Seg { from, .. } => samples.push(*from),
278 Piece::Arc {
279 centre,
280 radius,
281 start,
282 end,
283 } => {
284 for i in 0..32 {
285 let a = start + (end - start) * f64::from(i) / 32.0;
286 samples.push(at_angle(*centre, *radius, a));
287 }
288 }
289 }
290 }
291 for i in 0..samples.len() {
292 let (p, q) = (samples[i], samples[(i + 1) % samples.len()]);
293 area += p.x.mul_add(q.y, -(q.x * p.y));
294 }
295 if area.abs() <= tol.confusion() {
296 ogeom_bail!(Construction, "the wire encloses no area to offset");
297 }
298 area.signum()
299 };
300 let _ = source_count;
301 let outward = |tangent: Vector2| Vector2::new(tangent.y, -tangent.x) * winding;
304
305 let mut moved: Vec<Piece> = Vec::with_capacity(pieces.len());
307 for piece in &pieces {
308 match piece {
309 Piece::Seg { from, to } => {
310 let shift = outward(piece.tangent(false)) * offset;
311 moved.push(Piece::Seg {
312 from: *from + shift,
313 to: *to + shift,
314 });
315 }
316 Piece::Arc {
317 centre,
318 radius,
319 start,
320 end,
321 } => {
322 let mid = f64::midpoint(*start, *end);
325 let radial = (at_angle(*centre, *radius, mid) - *centre) / *radius;
326 let tangent_mid = if end > start {
327 Vector2::new(-radial.y, radial.x)
328 } else {
329 Vector2::new(radial.y, -radial.x)
330 };
331 let sign = outward(tangent_mid).dot(radial).signum();
332 let grown = radius + offset * sign;
333 if grown <= tol.confusion() {
334 ogeom_bail!(
335 Construction,
336 "the offset consumes the arc's radius entirely"
337 );
338 }
339 moved.push(Piece::Arc {
340 centre: *centre,
341 radius: grown,
342 start: *start,
343 end: *end,
344 });
345 }
346 }
347 }
348
349 let n = moved.len();
350 let mut chain: Vec<(Piece, Provenance)> = Vec::with_capacity(n * 2);
351 for (i, piece) in moved.iter().enumerate() {
352 chain.push((piece.clone(), Provenance::Offset(i)));
353 }
354 for i in 0..n {
358 let j = (i + 1) % n;
359 let turn = pieces[i].tangent(true).cross(pieces[j].tangent(false));
360 let at_i = chain
361 .iter()
362 .position(|(_, p)| *p == Provenance::Offset(i))
363 .unwrap_or(0);
364 let at_j = chain
365 .iter()
366 .position(|(_, p)| *p == Provenance::Offset(j))
367 .unwrap_or(0);
368 let e = chain[at_i].0.end_point();
369 let s = chain[at_j].0.start_point();
370 if e.distance(s) <= tol.confusion() * 10.0 {
371 continue; }
373 let corner = pieces[i].end_point();
374 let is_cap =
377 turn.abs() <= 1e-9 && pieces[i].tangent(true).dot(pieces[j].tangent(false)) < 0.0;
378 if is_cap && join == Join::Intersection {
379 let d = pieces[i].tangent(true);
382 let e_ext = e + d * offset.abs();
383 let s_ext = s + d * offset.abs();
384 chain.insert(
385 at_i + 1,
386 (Piece::Seg { from: e, to: e_ext }, Provenance::Join(i)),
387 );
388 chain.insert(
389 at_i + 2,
390 (
391 Piece::Seg {
392 from: e_ext,
393 to: s_ext,
394 },
395 Provenance::Join(i),
396 ),
397 );
398 chain.insert(
399 at_i + 3,
400 (Piece::Seg { from: s_ext, to: s }, Provenance::Join(i)),
401 );
402 continue;
403 }
404 if is_cap || turn * offset * winding > 0.0 {
405 match join {
407 Join::Arc => {
408 let a0 = (e - corner).y.atan2((e - corner).x);
409 let mut a1 = (s - corner).y.atan2((s - corner).x);
410 let tau = core::f64::consts::TAU;
415 while a1 - a0 > core::f64::consts::PI {
416 a1 -= tau;
417 }
418 while a0 - a1 > core::f64::consts::PI {
419 a1 += tau;
420 }
421 if ((a1 - a0).abs() - core::f64::consts::PI).abs() < 1e-9 {
422 let mid = at_angle(corner, offset.abs(), f64::midpoint(a0, a1));
423 let ahead = pieces[i].tangent(true);
424 if (mid - corner).dot(ahead) < 0.0 {
425 a1 -= tau * (a1 - a0).signum();
426 }
427 }
428 chain.insert(
429 at_i + 1,
430 (
431 Piece::Arc {
432 centre: corner,
433 radius: offset.abs(),
434 start: a0,
435 end: a1,
436 },
437 Provenance::Join(i),
438 ),
439 );
440 }
441 Join::Intersection => {
442 let (Piece::Seg { from: f1, to: t1 }, Piece::Seg { from: f2, to: t2 }) =
443 (chain[at_i].0.clone(), chain[at_j].0.clone())
444 else {
445 ogeom_bail!(
446 Construction,
447 "an intersection join between curved sides may \
448 never meet; use the arc join"
449 );
450 };
451 let met = intersect_lines(f1, t1, f2, t2, tol)?;
452 if let Piece::Seg { to, .. } = &mut chain[at_i].0 {
453 *to = met;
454 }
455 if let Piece::Seg { from, .. } = &mut chain[at_j].0 {
456 *from = met;
457 }
458 }
459 }
460 } else {
461 let met = nearest_crossing(&chain[at_i].0, &chain[at_j].0, corner, tol)?;
463 trim_end(&mut chain[at_i].0, met, tol)?;
464 trim_start(&mut chain[at_j].0, met, tol)?;
465 }
466 }
467 chain.retain(|(piece, _)| match piece {
470 Piece::Seg { from, to } => from.distance(*to) > tol.confusion(),
471 Piece::Arc { start, end, .. } => (end - start).abs() > tol.parametric(),
472 });
473 if chain.is_empty() {
474 ogeom_bail!(Construction, "the offset consumes the wire whole");
475 }
476
477 let m = chain.len();
482 let mut cuts: Vec<Vec<Point2>> = vec![Vec::new(); m];
483 for i in 0..m {
484 for j in i + 1..m {
485 if j == i + 1 || (i == 0 && j == m - 1) {
486 continue;
487 }
488 for p in crossings(&chain[i].0, &chain[j].0, tol)? {
489 if within(&chain[i].0, p, tol) && within(&chain[j].0, p, tol) {
490 cuts[i].push(p);
491 cuts[j].push(p);
492 }
493 }
494 }
495 }
496 let mut resolved: Vec<(Piece, Provenance)> = Vec::new();
497 for (k, (piece, provenance)) in chain.iter().enumerate() {
498 for sub in split_at(piece, &cuts[k]) {
499 resolved.push((sub, *provenance));
500 }
501 }
502
503 let source_distance = |p: Point2| -> f64 {
506 pieces
507 .iter()
508 .take(source_count)
509 .map(|piece| distance_to_piece(piece, p))
510 .fold(f64::INFINITY, f64::min)
511 };
512 let keep_beyond = offset.abs() - (tol.confusion() * 1e3).max(offset.abs() * 1e-3);
513 let had_cuts = cuts.iter().any(|c| !c.is_empty());
514 let survivors: Vec<(Piece, Provenance)> = resolved
515 .into_iter()
516 .filter(|(piece, _)| {
517 let mid = match piece {
518 Piece::Seg { from, to } => from.midpoint(*to),
519 Piece::Arc {
520 centre,
521 radius,
522 start,
523 end,
524 } => at_angle(*centre, *radius, f64::midpoint(*start, *end)),
525 };
526 source_distance(mid) >= keep_beyond
527 })
528 .collect();
529 if survivors.is_empty() {
530 ogeom_bail!(Construction, "the offset consumes the wire whole");
531 }
532
533 let loops: Vec<Vec<(Piece, Provenance)>> = if !had_cuts && survivors.len() == chain.len() {
535 vec![survivors]
536 } else {
537 let eps = tol.confusion() * 100.0;
538 let mut pool = survivors;
539 let mut out: Vec<Vec<(Piece, Provenance)>> = Vec::new();
540 while let Some(first) = pool.pop() {
541 let mut current = vec![first];
542 loop {
543 let tail = current.last().map(|(p, _)| p.end_point());
544 let Some(tail) = tail else { break };
545 let head = current.first().map(|(p, _)| p.start_point());
546 if head.is_some_and(|h| h.distance(tail) <= eps) && current.len() > 1 {
547 out.push(current);
548 break;
549 }
550 let Some(next) = pool
551 .iter()
552 .position(|(p, _)| p.start_point().distance(tail) <= eps)
553 else {
554 break;
557 };
558 current.push(pool.remove(next));
559 }
560 }
561 if out.is_empty() {
562 ogeom_bail!(
563 Construction,
564 "the offset's survivors close no loop; the collapse consumed \
565 the wire"
566 );
567 }
568 out
569 };
570
571 let lift = |p: Point2| frame.origin() + frame.x().vector() * p.x + frame.y().vector() * p.y;
575 let normal = frame.z();
576 let x_ref = frame.x();
577 let mut history = History::new();
578 let mut wires: Vec<Shape> = Vec::new();
579 for ring in &loops {
580 let count = ring.len();
581 let mut new_edges: Vec<Shape> = Vec::with_capacity(count);
582 let vertices: Vec<Shape> = (0..count)
583 .map(|k| make_vertex(model, lift(ring[k].0.start_point())).shape)
584 .collect();
585 for (k, (piece, provenance)) in ring.iter().enumerate() {
586 let from = &vertices[k];
587 let to = &vertices[(k + 1) % count];
588 let built = match piece {
589 Piece::Seg { from: a, to: b } => {
590 let line = LineCurve::segment(lift(*a), lift(*b), tol)?;
591 let curve = Curve::Line(line);
592 let domain = curve.domain();
593 make_edge_between(model, curve, domain, from, to, tol)?.shape
594 }
595 Piece::Arc {
596 centre,
597 radius,
598 start,
599 end,
600 } => {
601 let ccw = end > start;
604 let circle =
605 Circle::new(Frame::new(lift(*centre), normal, x_ref, tol)?, *radius, tol)?;
606 let curve = Curve::Circle(CircleCurve::new(circle));
607 let (lo, hi) = if ccw { (*start, *end) } else { (*end, *start) };
608 let (va, vb) = if ccw { (from, to) } else { (to, from) };
609 let edge = make_edge_between(model, curve, (lo, hi), va, vb, tol)?.shape;
610 if ccw { edge } else { edge.reversed() }
611 }
612 };
613 match provenance {
614 Provenance::Offset(i) => history.modify(&edges[i % edges.len()], built.clone()),
615 Provenance::Join(i) => {
616 if let Some((_, corner_vertex)) = edge_vertices(model, &edges[i % edges.len()])?
619 {
620 history.generate(&corner_vertex, built.clone());
621 }
622 }
623 }
624 new_edges.push(built);
625 }
626 wires.push(make_wire(model, &new_edges, tol)?.shape);
627 }
628 let shape = if wires.len() == 1 {
629 wires.remove(0)
630 } else {
631 ogeom_algo::build::make_compound(model, &wires)?.shape
632 };
633 history.modify(wire, shape.clone());
634 Ok(Built::new(shape, history))
635}
636
637fn split_at(piece: &Piece, points: &[Point2]) -> Vec<Piece> {
639 if points.is_empty() {
640 return vec![piece.clone()];
641 }
642 match piece {
643 Piece::Seg { from, to } => {
644 let d = *to - *from;
645 let len = d.magnitude();
646 let mut ts: Vec<f64> = points
647 .iter()
648 .map(|p| (*p - *from).dot(d / len))
649 .filter(|t| *t > 1e-12 && *t < len - 1e-12)
650 .collect();
651 ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
652 ts.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
653 let mut out = Vec::with_capacity(ts.len() + 1);
654 let mut last = *from;
655 for t in ts {
656 let p = *from + (d / len) * t;
657 out.push(Piece::Seg { from: last, to: p });
658 last = p;
659 }
660 out.push(Piece::Seg {
661 from: last,
662 to: *to,
663 });
664 out
665 }
666 Piece::Arc {
667 centre,
668 radius,
669 start,
670 end,
671 } => {
672 let toward = (end - start).signum();
673 let mut ts: Vec<f64> = points
674 .iter()
675 .filter_map(|p| {
676 let v = *p - *centre;
677 let mut a = v.y.atan2(v.x);
678 let tau = core::f64::consts::TAU;
679 while (a - start) * toward < 0.0 {
680 a += tau * toward;
681 }
682 while (a - end) * toward > 0.0 {
683 a -= tau * toward;
684 }
685 ((a - start) * toward > 1e-12 && (end - a) * toward > 1e-12).then_some(a)
686 })
687 .collect();
688 ts.sort_by(|a, b| {
689 ((a - start) * toward)
690 .partial_cmp(&((b - start) * toward))
691 .unwrap_or(core::cmp::Ordering::Equal)
692 });
693 ts.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
694 let mut out = Vec::with_capacity(ts.len() + 1);
695 let mut last = *start;
696 for t in ts {
697 out.push(Piece::Arc {
698 centre: *centre,
699 radius: *radius,
700 start: last,
701 end: t,
702 });
703 last = t;
704 }
705 out.push(Piece::Arc {
706 centre: *centre,
707 radius: *radius,
708 start: last,
709 end: *end,
710 });
711 out
712 }
713 }
714}
715
716#[derive(Debug, Clone, Copy, PartialEq, Eq)]
717enum Provenance {
718 Offset(usize),
719 Join(usize),
720}
721
722fn intersect_lines(
724 f1: Point2,
725 t1: Point2,
726 f2: Point2,
727 t2: Point2,
728 tol: Tolerances,
729) -> OgeomResult<Point2> {
730 let d1 = t1 - f1;
731 let d2 = t2 - f2;
732 let cross = d1.cross(d2);
733 if cross.abs() <= tol.angular() * d1.magnitude() * d2.magnitude() {
734 ogeom_bail!(Construction, "parallel sides never meet at a corner");
735 }
736 let t = (f2 - f1).cross(d2) / cross;
737 Ok(f1 + d1 * t)
738}
739
740fn nearest_crossing(a: &Piece, b: &Piece, corner: Point2, tol: Tolerances) -> OgeomResult<Point2> {
742 let candidates = crossings(a, b, tol)?;
743 candidates
744 .into_iter()
745 .min_by(|p, q| {
746 p.distance(corner)
747 .partial_cmp(&q.distance(corner))
748 .unwrap_or(core::cmp::Ordering::Equal)
749 })
750 .ok_or_else(|| {
751 ogeom_core::ogeom_err!(
752 Construction,
753 "overlapping offset pieces never cross; the offset collapses \
754 here"
755 )
756 })
757}
758
759fn crossings(a: &Piece, b: &Piece, tol: Tolerances) -> OgeomResult<Vec<Point2>> {
761 let support = |p: &Piece| -> OgeomResult<PlanarCurve> {
762 Ok(match p {
763 Piece::Seg { from, to } => ogeom_geom::Line2d::segment(*from, *to, tol)?.into(),
764 Piece::Arc { centre, radius, .. } => {
765 ogeom_geom::Circle2d::new(ogeom_math::Circle2::new(
766 ogeom_math::Frame2::new(*centre, ogeom_math::Direction2::X),
767 *radius,
768 tol,
769 )?)
770 .into()
771 }
772 })
773 };
774 let found = ogeom_intersect::intersect_curves_2d(
775 &support(a)?,
776 &support(b)?,
777 ogeom_intersect::CurveCurveOptions::default(),
778 tol,
779 )?;
780 Ok(found.crossings.into_iter().map(|c| c.point).collect())
781}
782
783fn distance_to_piece(piece: &Piece, p: Point2) -> f64 {
787 match piece {
788 Piece::Seg { from, to } => {
789 let d = *to - *from;
790 let len2 = d.dot(d);
791 let t = if len2 > 0.0 {
792 ((p - *from).dot(d) / len2).clamp(0.0, 1.0)
793 } else {
794 0.0
795 };
796 p.distance(*from + d * t)
797 }
798 Piece::Arc {
799 centre,
800 radius,
801 start,
802 end,
803 } => {
804 let (lo, hi) = if end > start {
805 (*start, *end)
806 } else {
807 (*end, *start)
808 };
809 let r = p - *centre;
810 let folded = lo + (r.y.atan2(r.x) - lo).rem_euclid(core::f64::consts::TAU);
811 if folded <= hi {
812 (r.magnitude() - radius).abs()
813 } else {
814 p.distance(at_angle(*centre, *radius, lo))
815 .min(p.distance(at_angle(*centre, *radius, hi)))
816 }
817 }
818 }
819}
820
821fn within(piece: &Piece, p: Point2, tol: Tolerances) -> bool {
824 let margin = tol.confusion() * 100.0;
825 match piece {
826 Piece::Seg { from, to } => {
827 let d = *to - *from;
828 let len = d.magnitude();
829 let t = (p - *from).dot(d / len);
830 t > margin && t < len - margin
831 }
832 Piece::Arc {
833 centre,
834 radius,
835 start,
836 end,
837 } => {
838 let a = (p - *centre).y.atan2((p - *centre).x);
839 let (lo, hi) = if end > start {
840 (*start, *end)
841 } else {
842 (*end, *start)
843 };
844 let tau = core::f64::consts::TAU;
845 let mut folded = a;
846 while folded < lo {
847 folded += tau;
848 }
849 folded > lo + margin / radius && folded < hi - margin / radius
850 }
851 }
852}
853
854fn trim_end(piece: &mut Piece, at: Point2, tol: Tolerances) -> OgeomResult<()> {
856 match piece {
857 Piece::Seg { from, to } => {
858 let d = (*to - *from).magnitude();
859 let kept = (at - *from).dot((*to - *from) / d);
860 if kept <= tol.confusion() {
861 ogeom_bail!(Construction, "the trim consumes the offset edge whole");
862 }
863 *to = at;
864 }
865 Piece::Arc {
866 centre,
867 radius,
868 start,
869 end,
870 } => {
871 let a = (at - *centre).y.atan2((at - *centre).x);
872 *end = align_angle(a, *start, *end, *radius, tol)?;
873 }
874 }
875 Ok(())
876}
877
878fn trim_start(piece: &mut Piece, at: Point2, tol: Tolerances) -> OgeomResult<()> {
880 match piece {
881 Piece::Seg { from, to } => {
882 let d = (*to - *from).magnitude();
883 let kept = (*to - at).dot((*to - *from) / d);
884 if kept <= tol.confusion() {
885 ogeom_bail!(Construction, "the trim consumes the offset edge whole");
886 }
887 *from = at;
888 }
889 Piece::Arc {
890 centre,
891 radius,
892 start,
893 end,
894 } => {
895 let a = (at - *centre).y.atan2((at - *centre).x);
896 *start = align_angle(a, *end, *start, *radius, tol)?;
897 }
898 }
899 Ok(())
900}
901
902fn align_angle(
905 angle: f64,
906 anchor: f64,
907 replaced: f64,
908 radius: f64,
909 tol: Tolerances,
910) -> OgeomResult<f64> {
911 let tau = core::f64::consts::TAU;
912 let mut a = angle;
913 while a - replaced > core::f64::consts::PI {
915 a -= tau;
916 }
917 while replaced - a > core::f64::consts::PI {
918 a += tau;
919 }
920 if ((a - anchor).abs() * radius) <= tol.confusion() {
921 ogeom_bail!(Construction, "the trim consumes the offset arc whole");
922 }
923 if (a - anchor).signum() != (replaced - anchor).signum() {
924 ogeom_bail!(Construction, "the trim runs past the offset arc's start");
925 }
926 Ok(a)
927}