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