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