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::{EdgeRepr, 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 (curve, range) = {
186 let Some(node) = model.node(edge) else {
187 ogeom_bail!(Dangling, "edge is not in this model");
188 };
189 let Some(data) = node.data().as_edge() else {
190 ogeom_bail!(Construction, "edge node holds no edge data");
191 };
192 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
193 ogeom_bail!(Construction, "an edge has no curve to offset");
194 };
195 let Some(geometry) = model.geometry().curve(*curve) else {
196 ogeom_bail!(Dangling, "curve is not in this model");
197 };
198 (geometry.clone(), *range)
199 };
200 let Some((sv, ev)) = edge_vertices(model, edge)? else {
201 ogeom_bail!(Construction, "an edge has no bounding vertices");
202 };
203 let position = |v: &Shape| -> OgeomResult<Point> {
204 let Some(node) = model.node(v) else {
205 ogeom_bail!(Dangling, "vertex is not in this model");
206 };
207 let Some(data) = node.data().as_vertex() else {
208 ogeom_bail!(Construction, "vertex node holds no point");
209 };
210 Ok(v.transform(model.datums())?.apply(data.point))
211 };
212 let from = flat(position(&sv)?);
213 let to = flat(position(&ev)?);
214 match &curve {
215 Curve::Line(_) => pieces.push(Piece::Seg { from, to }),
216 Curve::Circle(c) => {
217 let centre = flat(c.circle().centre());
218 let radius = c.circle().radius();
219 let mid = flat(curve.point_at(f64::midpoint(range.0, range.1), tol)?);
220 let ccw = (mid - from).cross(to - mid) > 0.0;
223 let a0 = (from - centre).y.atan2((from - centre).x);
224 let mut a1 = (to - centre).y.atan2((to - centre).x);
225 let tau = core::f64::consts::TAU;
226 if ccw {
227 while a1 <= a0 + tol.parametric() {
228 a1 += tau;
229 }
230 } else {
231 while a1 >= a0 - tol.parametric() {
232 a1 -= tau;
233 }
234 }
235 pieces.push(Piece::Arc {
236 centre,
237 radius,
238 start: a0,
239 end: a1,
240 });
241 }
242 _ => ogeom_bail!(
243 Construction,
244 "offsetting an edge that is neither straight nor circular \
245 needs the offset-curve machinery; see docs/PARITY.md, offset.wire-offset"
246 ),
247 }
248 }
249
250 let source_count = pieces.len();
255 if open {
256 for piece in pieces.clone().iter().rev() {
257 pieces.push(match piece {
258 Piece::Seg { from, to } => Piece::Seg {
259 from: *to,
260 to: *from,
261 },
262 Piece::Arc {
263 centre,
264 radius,
265 start,
266 end,
267 } => Piece::Arc {
268 centre: *centre,
269 radius: *radius,
270 start: *end,
271 end: *start,
272 },
273 });
274 }
275 }
276
277 let offset = if open { offset.abs() } else { offset };
282 let winding = if open {
283 1.0
284 } else {
285 let mut area = 0.0;
286 let mut samples: Vec<Point2> = Vec::new();
287 for piece in &pieces {
288 match piece {
289 Piece::Seg { from, .. } => samples.push(*from),
290 Piece::Arc {
291 centre,
292 radius,
293 start,
294 end,
295 } => {
296 for i in 0..32 {
297 let a = start + (end - start) * f64::from(i) / 32.0;
298 samples.push(at_angle(*centre, *radius, a));
299 }
300 }
301 }
302 }
303 for i in 0..samples.len() {
304 let (p, q) = (samples[i], samples[(i + 1) % samples.len()]);
305 area += p.x.mul_add(q.y, -(q.x * p.y));
306 }
307 if area.abs() <= tol.confusion() {
308 ogeom_bail!(Construction, "the wire encloses no area to offset");
309 }
310 area.signum()
311 };
312 let _ = source_count;
313 let outward = |tangent: Vector2| Vector2::new(tangent.y, -tangent.x) * winding;
316
317 let mut moved: Vec<Piece> = Vec::with_capacity(pieces.len());
319 for piece in &pieces {
320 match piece {
321 Piece::Seg { from, to } => {
322 let shift = outward(piece.tangent(false)) * offset;
323 moved.push(Piece::Seg {
324 from: *from + shift,
325 to: *to + shift,
326 });
327 }
328 Piece::Arc {
329 centre,
330 radius,
331 start,
332 end,
333 } => {
334 let mid = f64::midpoint(*start, *end);
337 let radial = (at_angle(*centre, *radius, mid) - *centre) / *radius;
338 let tangent_mid = if end > start {
339 Vector2::new(-radial.y, radial.x)
340 } else {
341 Vector2::new(radial.y, -radial.x)
342 };
343 let sign = outward(tangent_mid).dot(radial).signum();
344 let grown = radius + offset * sign;
345 if grown <= tol.confusion() {
346 ogeom_bail!(
347 Construction,
348 "the offset consumes the arc's radius entirely"
349 );
350 }
351 moved.push(Piece::Arc {
352 centre: *centre,
353 radius: grown,
354 start: *start,
355 end: *end,
356 });
357 }
358 }
359 }
360
361 let n = moved.len();
362 let mut chain: Vec<(Piece, Provenance)> = Vec::with_capacity(n * 2);
363 for (i, piece) in moved.iter().enumerate() {
364 chain.push((piece.clone(), Provenance::Offset(i)));
365 }
366 for i in 0..n {
370 let j = (i + 1) % n;
371 let turn = pieces[i].tangent(true).cross(pieces[j].tangent(false));
372 let at_i = chain
373 .iter()
374 .position(|(_, p)| *p == Provenance::Offset(i))
375 .unwrap_or(0);
376 let at_j = chain
377 .iter()
378 .position(|(_, p)| *p == Provenance::Offset(j))
379 .unwrap_or(0);
380 let e = chain[at_i].0.end_point();
381 let s = chain[at_j].0.start_point();
382 if e.distance(s) <= tol.confusion() * 10.0 {
383 continue; }
385 let corner = pieces[i].end_point();
386 let is_cap =
389 turn.abs() <= 1e-9 && pieces[i].tangent(true).dot(pieces[j].tangent(false)) < 0.0;
390 if is_cap && join == Join::Intersection {
391 let d = pieces[i].tangent(true);
394 let e_ext = e + d * offset.abs();
395 let s_ext = s + d * offset.abs();
396 chain.insert(
397 at_i + 1,
398 (Piece::Seg { from: e, to: e_ext }, Provenance::Join(i)),
399 );
400 chain.insert(
401 at_i + 2,
402 (
403 Piece::Seg {
404 from: e_ext,
405 to: s_ext,
406 },
407 Provenance::Join(i),
408 ),
409 );
410 chain.insert(
411 at_i + 3,
412 (Piece::Seg { from: s_ext, to: s }, Provenance::Join(i)),
413 );
414 continue;
415 }
416 if is_cap || turn * offset * winding > 0.0 {
417 match join {
419 Join::Arc => {
420 let a0 = (e - corner).y.atan2((e - corner).x);
421 let mut a1 = (s - corner).y.atan2((s - corner).x);
422 let tau = core::f64::consts::TAU;
427 while a1 - a0 > core::f64::consts::PI {
428 a1 -= tau;
429 }
430 while a0 - a1 > core::f64::consts::PI {
431 a1 += tau;
432 }
433 if ((a1 - a0).abs() - core::f64::consts::PI).abs() < 1e-9 {
434 let mid = at_angle(corner, offset.abs(), f64::midpoint(a0, a1));
435 let ahead = pieces[i].tangent(true);
436 if (mid - corner).dot(ahead) < 0.0 {
437 a1 -= tau * (a1 - a0).signum();
438 }
439 }
440 chain.insert(
441 at_i + 1,
442 (
443 Piece::Arc {
444 centre: corner,
445 radius: offset.abs(),
446 start: a0,
447 end: a1,
448 },
449 Provenance::Join(i),
450 ),
451 );
452 }
453 Join::Intersection => {
454 let (Piece::Seg { from: f1, to: t1 }, Piece::Seg { from: f2, to: t2 }) =
455 (chain[at_i].0.clone(), chain[at_j].0.clone())
456 else {
457 ogeom_bail!(
458 Construction,
459 "an intersection join between curved sides may \
460 never meet; use the arc join"
461 );
462 };
463 let met = intersect_lines(f1, t1, f2, t2, tol)?;
464 if let Piece::Seg { to, .. } = &mut chain[at_i].0 {
465 *to = met;
466 }
467 if let Piece::Seg { from, .. } = &mut chain[at_j].0 {
468 *from = met;
469 }
470 }
471 }
472 } else {
473 let met = nearest_crossing(&chain[at_i].0, &chain[at_j].0, corner, tol)?;
475 trim_end(&mut chain[at_i].0, met, tol)?;
476 trim_start(&mut chain[at_j].0, met, tol)?;
477 }
478 }
479 chain.retain(|(piece, _)| match piece {
482 Piece::Seg { from, to } => from.distance(*to) > tol.confusion(),
483 Piece::Arc { start, end, .. } => (end - start).abs() > tol.parametric(),
484 });
485 if chain.is_empty() {
486 ogeom_bail!(Construction, "the offset consumes the wire whole");
487 }
488
489 let m = chain.len();
494 let mut cuts: Vec<Vec<Point2>> = vec![Vec::new(); m];
495 for i in 0..m {
496 for j in i + 1..m {
497 if j == i + 1 || (i == 0 && j == m - 1) {
498 continue;
499 }
500 for p in crossings(&chain[i].0, &chain[j].0, tol)? {
501 if within(&chain[i].0, p, tol) && within(&chain[j].0, p, tol) {
502 cuts[i].push(p);
503 cuts[j].push(p);
504 }
505 }
506 }
507 }
508 let mut resolved: Vec<(Piece, Provenance)> = Vec::new();
509 for (k, (piece, provenance)) in chain.iter().enumerate() {
510 for sub in split_at(piece, &cuts[k]) {
511 resolved.push((sub, *provenance));
512 }
513 }
514
515 let source_distance = |p: Point2| -> f64 {
518 pieces
519 .iter()
520 .take(source_count)
521 .map(|piece| distance_to_piece(piece, p))
522 .fold(f64::INFINITY, f64::min)
523 };
524 let keep_beyond = offset.abs() - (tol.confusion() * 1e3).max(offset.abs() * 1e-3);
525 let had_cuts = cuts.iter().any(|c| !c.is_empty());
526 let survivors: Vec<(Piece, Provenance)> = resolved
527 .into_iter()
528 .filter(|(piece, _)| {
529 let mid = match piece {
530 Piece::Seg { from, to } => from.midpoint(*to),
531 Piece::Arc {
532 centre,
533 radius,
534 start,
535 end,
536 } => at_angle(*centre, *radius, f64::midpoint(*start, *end)),
537 };
538 source_distance(mid) >= keep_beyond
539 })
540 .collect();
541 if survivors.is_empty() {
542 ogeom_bail!(Construction, "the offset consumes the wire whole");
543 }
544
545 let loops: Vec<Vec<(Piece, Provenance)>> = if !had_cuts && survivors.len() == chain.len() {
547 vec![survivors]
548 } else {
549 let eps = tol.confusion() * 100.0;
550 let mut pool = survivors;
551 let mut out: Vec<Vec<(Piece, Provenance)>> = Vec::new();
552 while let Some(first) = pool.pop() {
553 let mut current = vec![first];
554 loop {
555 let tail = current.last().map(|(p, _)| p.end_point());
556 let Some(tail) = tail else { break };
557 let head = current.first().map(|(p, _)| p.start_point());
558 if head.is_some_and(|h| h.distance(tail) <= eps) && current.len() > 1 {
559 out.push(current);
560 break;
561 }
562 let Some(next) = pool
563 .iter()
564 .position(|(p, _)| p.start_point().distance(tail) <= eps)
565 else {
566 break;
569 };
570 current.push(pool.remove(next));
571 }
572 }
573 if out.is_empty() {
574 ogeom_bail!(
575 Construction,
576 "the offset's survivors close no loop; the collapse consumed \
577 the wire"
578 );
579 }
580 out
581 };
582
583 let lift = |p: Point2| frame.origin() + frame.x().vector() * p.x + frame.y().vector() * p.y;
587 let normal = frame.z();
588 let x_ref = frame.x();
589 let mut history = History::new();
590 let mut wires: Vec<Shape> = Vec::new();
591 for ring in &loops {
592 let count = ring.len();
593 let mut new_edges: Vec<Shape> = Vec::with_capacity(count);
594 let vertices: Vec<Shape> = (0..count)
595 .map(|k| make_vertex(model, lift(ring[k].0.start_point())).shape)
596 .collect();
597 for (k, (piece, provenance)) in ring.iter().enumerate() {
598 let from = &vertices[k];
599 let to = &vertices[(k + 1) % count];
600 let built = match piece {
601 Piece::Seg { from: a, to: b } => {
602 let line = LineCurve::segment(lift(*a), lift(*b), tol)?;
603 let curve = Curve::Line(line);
604 let domain = curve.domain();
605 make_edge_between(model, curve, domain, from, to, tol)?.shape
606 }
607 Piece::Arc {
608 centre,
609 radius,
610 start,
611 end,
612 } => {
613 let ccw = end > start;
616 let circle =
617 Circle::new(Frame::new(lift(*centre), normal, x_ref, tol)?, *radius, tol)?;
618 let curve = Curve::Circle(CircleCurve::new(circle));
619 let (lo, hi) = if ccw { (*start, *end) } else { (*end, *start) };
620 let (va, vb) = if ccw { (from, to) } else { (to, from) };
621 let edge = make_edge_between(model, curve, (lo, hi), va, vb, tol)?.shape;
622 if ccw { edge } else { edge.reversed() }
623 }
624 };
625 match provenance {
626 Provenance::Offset(i) => history.modify(&edges[i % edges.len()], built.clone()),
627 Provenance::Join(i) => {
628 if let Some((_, corner_vertex)) = edge_vertices(model, &edges[i % edges.len()])?
631 {
632 history.generate(&corner_vertex, built.clone());
633 }
634 }
635 }
636 new_edges.push(built);
637 }
638 wires.push(make_wire(model, &new_edges, tol)?.shape);
639 }
640 let shape = if wires.len() == 1 {
641 wires.remove(0)
642 } else {
643 ogeom_algo::build::make_compound(model, &wires)?.shape
644 };
645 history.modify(wire, shape.clone());
646 Ok(Built::new(shape, history))
647}
648
649fn split_at(piece: &Piece, points: &[Point2]) -> Vec<Piece> {
651 if points.is_empty() {
652 return vec![piece.clone()];
653 }
654 match piece {
655 Piece::Seg { from, to } => {
656 let d = *to - *from;
657 let len = d.magnitude();
658 let mut ts: Vec<f64> = points
659 .iter()
660 .map(|p| (*p - *from).dot(d / len))
661 .filter(|t| *t > 1e-12 && *t < len - 1e-12)
662 .collect();
663 ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
664 ts.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
665 let mut out = Vec::with_capacity(ts.len() + 1);
666 let mut last = *from;
667 for t in ts {
668 let p = *from + (d / len) * t;
669 out.push(Piece::Seg { from: last, to: p });
670 last = p;
671 }
672 out.push(Piece::Seg {
673 from: last,
674 to: *to,
675 });
676 out
677 }
678 Piece::Arc {
679 centre,
680 radius,
681 start,
682 end,
683 } => {
684 let toward = (end - start).signum();
685 let mut ts: Vec<f64> = points
686 .iter()
687 .filter_map(|p| {
688 let v = *p - *centre;
689 let mut a = v.y.atan2(v.x);
690 let tau = core::f64::consts::TAU;
691 while (a - start) * toward < 0.0 {
692 a += tau * toward;
693 }
694 while (a - end) * toward > 0.0 {
695 a -= tau * toward;
696 }
697 ((a - start) * toward > 1e-12 && (end - a) * toward > 1e-12).then_some(a)
698 })
699 .collect();
700 ts.sort_by(|a, b| {
701 ((a - start) * toward)
702 .partial_cmp(&((b - start) * toward))
703 .unwrap_or(core::cmp::Ordering::Equal)
704 });
705 ts.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
706 let mut out = Vec::with_capacity(ts.len() + 1);
707 let mut last = *start;
708 for t in ts {
709 out.push(Piece::Arc {
710 centre: *centre,
711 radius: *radius,
712 start: last,
713 end: t,
714 });
715 last = t;
716 }
717 out.push(Piece::Arc {
718 centre: *centre,
719 radius: *radius,
720 start: last,
721 end: *end,
722 });
723 out
724 }
725 }
726}
727
728#[derive(Debug, Clone, Copy, PartialEq, Eq)]
729enum Provenance {
730 Offset(usize),
731 Join(usize),
732}
733
734fn intersect_lines(
736 f1: Point2,
737 t1: Point2,
738 f2: Point2,
739 t2: Point2,
740 tol: Tolerances,
741) -> OgeomResult<Point2> {
742 let d1 = t1 - f1;
743 let d2 = t2 - f2;
744 let cross = d1.cross(d2);
745 if cross.abs() <= tol.angular() * d1.magnitude() * d2.magnitude() {
746 ogeom_bail!(Construction, "parallel sides never meet at a corner");
747 }
748 let t = (f2 - f1).cross(d2) / cross;
749 Ok(f1 + d1 * t)
750}
751
752fn nearest_crossing(a: &Piece, b: &Piece, corner: Point2, tol: Tolerances) -> OgeomResult<Point2> {
754 let candidates = crossings(a, b, tol)?;
755 candidates
756 .into_iter()
757 .min_by(|p, q| {
758 p.distance(corner)
759 .partial_cmp(&q.distance(corner))
760 .unwrap_or(core::cmp::Ordering::Equal)
761 })
762 .ok_or_else(|| {
763 ogeom_core::ogeom_err!(
764 Construction,
765 "overlapping offset pieces never cross; the offset collapses \
766 here"
767 )
768 })
769}
770
771fn crossings(a: &Piece, b: &Piece, tol: Tolerances) -> OgeomResult<Vec<Point2>> {
773 let support = |p: &Piece| -> OgeomResult<PlanarCurve> {
774 Ok(match p {
775 Piece::Seg { from, to } => ogeom_geom::Line2d::segment(*from, *to, tol)?.into(),
776 Piece::Arc { centre, radius, .. } => {
777 ogeom_geom::Circle2d::new(ogeom_math::Circle2::new(
778 ogeom_math::Frame2::new(*centre, ogeom_math::Direction2::X),
779 *radius,
780 tol,
781 )?)
782 .into()
783 }
784 })
785 };
786 let found = ogeom_intersect::intersect_curves_2d(
787 &support(a)?,
788 &support(b)?,
789 ogeom_intersect::CurveCurveOptions::default(),
790 tol,
791 )?;
792 Ok(found.crossings.into_iter().map(|c| c.point).collect())
793}
794
795fn distance_to_piece(piece: &Piece, p: Point2) -> f64 {
799 match piece {
800 Piece::Seg { from, to } => {
801 let d = *to - *from;
802 let len2 = d.dot(d);
803 let t = if len2 > 0.0 {
804 ((p - *from).dot(d) / len2).clamp(0.0, 1.0)
805 } else {
806 0.0
807 };
808 p.distance(*from + d * t)
809 }
810 Piece::Arc {
811 centre,
812 radius,
813 start,
814 end,
815 } => {
816 let (lo, hi) = if end > start {
817 (*start, *end)
818 } else {
819 (*end, *start)
820 };
821 let r = p - *centre;
822 let folded = lo + (r.y.atan2(r.x) - lo).rem_euclid(core::f64::consts::TAU);
823 if folded <= hi {
824 (r.magnitude() - radius).abs()
825 } else {
826 p.distance(at_angle(*centre, *radius, lo))
827 .min(p.distance(at_angle(*centre, *radius, hi)))
828 }
829 }
830 }
831}
832
833fn within(piece: &Piece, p: Point2, tol: Tolerances) -> bool {
836 let margin = tol.confusion() * 100.0;
837 match piece {
838 Piece::Seg { from, to } => {
839 let d = *to - *from;
840 let len = d.magnitude();
841 let t = (p - *from).dot(d / len);
842 t > margin && t < len - margin
843 }
844 Piece::Arc {
845 centre,
846 radius,
847 start,
848 end,
849 } => {
850 let a = (p - *centre).y.atan2((p - *centre).x);
851 let (lo, hi) = if end > start {
852 (*start, *end)
853 } else {
854 (*end, *start)
855 };
856 let tau = core::f64::consts::TAU;
857 let mut folded = a;
858 while folded < lo {
859 folded += tau;
860 }
861 folded > lo + margin / radius && folded < hi - margin / radius
862 }
863 }
864}
865
866fn trim_end(piece: &mut Piece, at: Point2, tol: Tolerances) -> OgeomResult<()> {
868 match piece {
869 Piece::Seg { from, to } => {
870 let d = (*to - *from).magnitude();
871 let kept = (at - *from).dot((*to - *from) / d);
872 if kept <= tol.confusion() {
873 ogeom_bail!(Construction, "the trim consumes the offset edge whole");
874 }
875 *to = at;
876 }
877 Piece::Arc {
878 centre,
879 radius,
880 start,
881 end,
882 } => {
883 let a = (at - *centre).y.atan2((at - *centre).x);
884 *end = align_angle(a, *start, *end, *radius, tol)?;
885 }
886 }
887 Ok(())
888}
889
890fn trim_start(piece: &mut Piece, at: Point2, tol: Tolerances) -> OgeomResult<()> {
892 match piece {
893 Piece::Seg { from, to } => {
894 let d = (*to - *from).magnitude();
895 let kept = (*to - at).dot((*to - *from) / d);
896 if kept <= tol.confusion() {
897 ogeom_bail!(Construction, "the trim consumes the offset edge whole");
898 }
899 *from = at;
900 }
901 Piece::Arc {
902 centre,
903 radius,
904 start,
905 end,
906 } => {
907 let a = (at - *centre).y.atan2((at - *centre).x);
908 *start = align_angle(a, *end, *start, *radius, tol)?;
909 }
910 }
911 Ok(())
912}
913
914fn align_angle(
917 angle: f64,
918 anchor: f64,
919 replaced: f64,
920 radius: f64,
921 tol: Tolerances,
922) -> OgeomResult<f64> {
923 let tau = core::f64::consts::TAU;
924 let mut a = angle;
925 while a - replaced > core::f64::consts::PI {
927 a -= tau;
928 }
929 while replaced - a > core::f64::consts::PI {
930 a += tau;
931 }
932 if ((a - anchor).abs() * radius) <= tol.confusion() {
933 ogeom_bail!(Construction, "the trim consumes the offset arc whole");
934 }
935 if (a - anchor).signum() != (replaced - anchor).signum() {
936 ogeom_bail!(Construction, "the trim runs past the offset arc's start");
937 }
938 Ok(a)
939}