1use ogeom_algo::Built;
15use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
16use ogeom_geom::{PlaneSurface, Surface as _, SurfaceGeometry};
17use ogeom_math::{Direction, Frame, Plane, Point, Transform, Vector};
18use ogeom_topo::{Model, NodeData, Shape, ShapeType, TShapeId};
19
20use crate::shape::rebuilt;
21
22pub fn apply_draft(
39 model: &mut Model,
40 solid: &Shape,
41 faces: &[Shape],
42 neutral: Plane,
43 pull: Direction,
44 angle: f64,
45 tol: Tolerances,
46) -> OgeomResult<Built> {
47 if !angle.is_finite() || angle.abs() >= core::f64::consts::FRAC_PI_2 {
48 ogeom_bail!(
49 Construction,
50 "a draft of {angle} radians turns the face past its own plane"
51 );
52 }
53 let (canonical, mapped, prefix) = crate::shape::canonical_input(model, solid, faces, tol)?;
54 if let Some(prefix) = prefix {
55 let mut out = apply_draft(model, &canonical, &mapped, neutral, pull, angle, tol)?;
56 out.history = prefix.then(&out.history);
57 return Ok(out);
58 }
59 if faces.is_empty() {
60 ogeom_bail!(Construction, "a draft of no faces drafts nothing");
61 }
62 let own: Vec<Shape> = {
66 let mut seen: Vec<Shape> = Vec::new();
67 for f in ogeom_topo::explore(model, solid, ogeom_topo::Filter::OfType(ShapeType::Face))? {
68 if !seen.iter().any(|s| s.node() == f.node()) {
69 seen.push(f);
70 }
71 }
72 seen
73 };
74
75 let mut turned: Vec<(TShapeId, SurfaceGeometry)> = Vec::with_capacity(faces.len());
79 for face in faces {
80 let Some(used) = own.iter().find(|f| f.node() == face.node()).cloned() else {
81 ogeom_bail!(Construction, "a drafted face is not a face of the solid");
82 };
83 let face = &used;
84 let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data().clone()) else {
85 ogeom_bail!(Construction, "expected a face");
86 };
87 let Some(surface) = model.geometry().surface(data.surface) else {
88 ogeom_bail!(Dangling, "face refers to a surface not in this model");
89 };
90 let sign = outward_sign(model, solid, face, surface, tol)?;
95 let sign_of = |_: &Shape| sign;
96 let axial = |frame: Frame| -> bool {
99 (frame.z().vector().dot(neutral.normal().vector()).abs() - 1.0).abs()
100 <= tol.angular().max(1e-9)
101 };
102 match surface {
103 SurfaceGeometry::Cylinder(c) if axial(c.cylinder().frame()) => {
104 let cylinder = c.cylinder();
105 let (_, (v0, v1)) = surface.domain();
106 turned.push((
107 face.node(),
108 revolved_draft(
109 cylinder.frame(),
110 cylinder.radius(),
111 0.0,
112 (v0, v1),
113 sign_of(face),
114 neutral,
115 pull,
116 angle,
117 tol,
118 )?,
119 ));
120 continue;
121 }
122 SurfaceGeometry::Cone(co) if axial(co.cone().frame()) => {
123 let cone = co.cone();
124 let (_, (v0, v1)) = surface.domain();
125 turned.push((
126 face.node(),
127 revolved_draft(
128 cone.frame(),
129 cone.reference_radius(),
130 cone.half_angle(),
131 (v0, v1),
132 sign_of(face),
133 neutral,
134 pull,
135 angle,
136 tol,
137 )?,
138 ));
139 continue;
140 }
141 SurfaceGeometry::Extrusion(e) => {
142 turned.push((
143 face.node(),
144 extruded_draft(
145 e,
146 surface.domain(),
147 sign_of(face),
148 neutral,
149 pull,
150 angle,
151 tol,
152 )?,
153 ));
154 continue;
155 }
156 SurfaceGeometry::Plane(_) => {}
157 _ => {
162 turned.push((
163 face.node(),
164 general_draft(model, face, surface, sign, neutral, pull, angle, tol)?,
165 ));
166 continue;
167 }
168 }
169 let SurfaceGeometry::Plane(p) = surface else {
170 unreachable!("the match above let only planes through");
171 };
172 let plane = p.plane();
173 let ((u0, u1), (v0, v1)) = surface.domain();
174 let outward = plane.normal().vector() * sign;
177
178 let along = plane.normal().vector().cross(neutral.normal().vector());
180 let magnitude = along.magnitude();
181 if magnitude <= tol.angular() {
182 ogeom_bail!(
183 Construction,
184 "a face parallel to the neutral plane has no line to turn \
185 about"
186 );
187 }
188 let along = along / magnitude;
189 let hinge = meet(plane, neutral, along, tol)?;
190
191 let axis = ogeom_math::Axis::new(hinge, Direction::new(along, tol)?);
196 let mut candidates = Vec::with_capacity(2);
197 for sense in [1.0, -1.0] {
198 let turn = Transform::rotation(axis, angle.abs() * sense);
199 candidates.push((sense, turn.apply_vector(outward).dot(pull.vector())));
200 }
201 let leaning = candidates
202 .iter()
203 .copied()
204 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal))
205 .map_or(1.0, |(sense, _)| sense);
206 let turn = Transform::rotation(axis, angle * leaning);
207 let moved_normal = Direction::new(turn.apply_vector(plane.normal().vector()), tol)?;
208 let tilted = Plane::new(Frame::new(
209 hinge,
210 moved_normal,
211 Direction::new(along, tol)?,
212 tol,
213 )?);
214 let grow = (u1 - u0).abs().max((v1 - v0).abs()).mul_add(0.5, 1.0) * angle.abs().tan()
217 + tol.confusion();
218 turned.push((
219 face.node(),
220 PlaneSurface::over(tilted, (u0 - grow, u1 + grow), (v0 - grow, v1 + grow))?.into(),
221 ));
222 }
223
224 rebuilt(
225 model,
226 solid,
227 &|_| 0.0,
228 &|face| {
229 turned
230 .iter()
231 .find(|(node, _)| *node == face.node())
232 .map(|(_, surface)| surface.clone())
233 },
234 tol,
235 )
236}
237
238#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
242fn revolved_draft(
243 frame: Frame,
244 reference_radius: f64,
245 half_angle: f64,
246 window: (f64, f64),
247 sign: f64,
248 neutral: Plane,
249 pull: Direction,
250 angle: f64,
251 tol: Tolerances,
252) -> OgeomResult<SurfaceGeometry> {
253 use ogeom_geom::ConeSurface;
254
255 let axis_dir = frame.z().vector();
256 let along = axis_dir.dot(neutral.normal().vector());
257 if (along.abs() - 1.0).abs() > tol.angular().max(1e-9) {
258 ogeom_bail!(
259 Construction,
260 "a wall of revolution drafts about a neutral plane square to \
261 its axis; the oblique neutral needs the general machinery (see \
262 docs/PARITY.md, offset.draft)"
263 );
264 }
265 let height = -neutral.signed_distance_to(frame.origin()) * along.signum();
268 let neutral_point = frame.origin() + axis_dir * height;
269 let neutral_radius = half_angle.tan().mul_add(height, reference_radius);
270 if neutral_radius <= tol.confusion() {
271 ogeom_bail!(
272 Construction,
273 "the wall has no radius left at the neutral plane to hold"
274 );
275 }
276 let hinge_frame = Frame::new(neutral_point, frame.z(), frame.x(), tol)?;
277
278 let mut best: Option<(f64, f64)> = None;
282 for sense in [1.0_f64, -1.0] {
283 let probe = half_angle + angle.abs() * sense;
286 let candidate = half_angle + angle * sense;
287 if probe.abs() <= tol.angular()
288 || probe.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular()
289 || candidate.abs() <= tol.angular()
290 || candidate.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular()
291 {
292 continue;
293 }
294 let cone = ogeom_math::Cone::new(hinge_frame, neutral_radius, probe, tol)?;
295 let surface: SurfaceGeometry = ConeSurface::new(cone, (-1.0, 1.0))?.into();
296 let (du, dv) = surface.d1_at(0.0, 1.0, tol)?;
297 let n = du.cross(dv);
298 let outward = n / n.magnitude() * sign;
299 let lean = outward.dot(pull.vector());
300 if best.as_ref().is_none_or(|(_, held)| lean > *held) {
301 best = Some((candidate, lean));
302 }
303 }
304 let Some((leaned, _)) = best else {
305 ogeom_bail!(
306 Construction,
307 "a draft of {angle} radians flattens the wall or swallows it"
308 );
309 };
310 let cone = ogeom_math::Cone::new(hinge_frame, neutral_radius, leaned, tol)?;
311
312 let shift = height;
315 let grow = (window.1 - window.0).abs().mul_add(0.1, 1.0);
316 let (w0, w1) = (window.0 - shift - grow, window.1 - shift + grow);
317 let apex_height = -neutral_radius / leaned.tan();
318 if apex_height > w0 && apex_height < w1 {
319 ogeom_bail!(
320 Construction,
321 "the draft swallows the drafted face's own apex"
322 );
323 }
324 Ok(ConeSurface::new(cone, (w0, w1))?.into())
325}
326
327#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
339fn extruded_draft(
340 extrusion: &ogeom_geom::ExtrusionSurface,
341 window: ((f64, f64), (f64, f64)),
342 sign: f64,
343 neutral: Plane,
344 pull: Direction,
345 angle: f64,
346 tol: Tolerances,
347) -> OgeomResult<SurfaceGeometry> {
348 use ogeom_geom::Curve3d as _;
349 let ((u0, u1), (v0, v1)) = window;
350 let d = extrusion.direction().vector();
351 let n = neutral.normal().vector();
352 let den = n.dot(d);
353 if den.abs() <= tol.angular() {
354 ogeom_bail!(
355 Construction,
356 "the neutral plane runs along the wall's rulings; there is no \
357 hinge to turn about"
358 );
359 }
360 let curve = extrusion.curve();
361 let o = neutral.origin().to_vector();
362 let height_at = |c: Point| n.dot(o - c.to_vector()) / den;
365 let hinge_tangent = |cd: Vector| cd - d * (n.dot(cd) / den);
366
367 let um = f64::midpoint(u0, u1);
372 let cm = curve.point_at(um, tol)?;
373 let cdm = curve.d1_at(um, tol)?;
374 let hinge_m = cm + d * height_at(cm);
375 let tangent_m = Direction::new(hinge_tangent(cdm), tol)?;
376 let outward = {
377 let nw = cdm.cross(d);
378 nw / nw.magnitude() * sign
379 };
380 let axis_m = ogeom_math::Axis::new(hinge_m, tangent_m);
381 let mut leaning = 1.0;
382 let mut best = f64::NEG_INFINITY;
383 for sense in [1.0_f64, -1.0] {
384 let turn = Transform::rotation(axis_m, angle.abs() * sense);
385 let lean = turn.apply_vector(outward).dot(pull.vector());
386 if lean > best {
387 best = lean;
388 leaning = sense;
389 }
390 }
391 let theta = angle * leaning;
392
393 let ruling_at = |u: f64| -> OgeomResult<(Point, Vector, f64)> {
396 let c = curve.point_at(u, tol)?;
397 let cd = curve.d1_at(u, tol)?;
398 let h = height_at(c);
399 let hinge = c + d * h;
400 let tangent = Direction::new(hinge_tangent(cd), tol)?;
401 let turn = Transform::rotation(ogeom_math::Axis::new(hinge, tangent), theta);
402 Ok((hinge, turn.apply_vector(d), h))
403 };
404 const ALONG: usize = 65;
405 #[allow(clippy::cast_precision_loss)]
406 let along_u = |x: f64| u0 + (u1 - u0) * x / ((ALONG - 1) as f64);
407 let mut base: Vec<(Point, Vector)> = Vec::with_capacity(ALONG);
408 let (mut s_lo, mut s_hi) = (f64::INFINITY, f64::NEG_INFINITY);
409 for i in 0..ALONG {
410 #[allow(clippy::cast_precision_loss)]
411 let (hinge, ruling, h) = ruling_at(along_u(i as f64))?;
412 base.push((hinge, ruling));
413 s_lo = s_lo.min(v0 - h);
414 s_hi = s_hi.max(v1 - h);
415 }
416 let grow = (u1 - u0).abs().max((v1 - v0).abs()).mul_add(0.5, 1.0) * angle.abs().tan()
421 + tol.confusion();
422 let (s_lo, s_hi) = (s_lo - grow, s_hi + grow);
423 struct Continuation {
427 hinge: Point,
428 d1: Vector,
429 d2: Vector,
430 ruling: Vector,
431 r1: Vector,
432 steps: usize,
433 }
434 let continuation = |front: bool| -> Continuation {
435 let (i0, i1, i2) = if front {
436 (0, 1, 2)
437 } else {
438 (ALONG - 1, ALONG - 2, ALONG - 3)
439 };
440 let d1 = base[i0].0 - base[i1].0;
441 let d2 = (base[i0].0 - base[i1].0) - (base[i1].0 - base[i2].0);
442 let step = d1.magnitude().max(tol.confusion());
447 let ruling = base[i0].1;
448 let lean = (ruling - d * ruling.dot(d)).magnitude() * s_lo.abs().max(s_hi.abs());
449 let steps = ((grow + lean) / step).ceil().max(2.0);
450 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
451 let steps = (steps as usize).min(16);
452 Continuation {
453 hinge: base[i0].0,
454 d1,
455 d2,
456 ruling: base[i0].1,
457 r1: base[i0].1 - base[i1].1,
458 steps,
459 }
460 };
461 let (front, back) = (continuation(true), continuation(false));
462 let continued = |c: &Continuation, k: f64| -> (Point, Vector) {
463 (
464 c.hinge + c.d1 * k + c.d2 * (k * (k + 1.0) / 2.0),
465 c.ruling + c.r1 * k,
466 )
467 };
468 #[allow(clippy::cast_precision_loss)]
473 let (first, last) = (front.steps as f64, (front.steps + ALONG - 1) as f64);
474 let station = |x: f64| -> OgeomResult<(Point, Vector)> {
475 if x < first {
476 return Ok(continued(&front, first - x));
477 }
478 if x > last {
479 return Ok(continued(&back, x - last));
480 }
481 let (hinge, ruling, _) = ruling_at(along_u(x - first))?;
482 Ok((hinge, ruling))
483 };
484 let along_total = front.steps + ALONG + back.steps;
485 let stations: Vec<(Point, Vector)> = (0..along_total)
486 .map(|i| {
487 #[allow(clippy::cast_precision_loss)]
488 let x = i as f64;
489 match i.checked_sub(front.steps) {
490 Some(j) if j < ALONG => Ok(base[j]),
491 _ => station(x),
492 }
493 })
494 .collect::<OgeomResult<_>>()?;
495
496 for edge in [s_lo, s_hi] {
499 for pair in stations.windows(2) {
500 let ((h0, r0), (h1, r1)) = (pair[0], pair[1]);
501 let step = (h1 + r1 * edge) - (h0 + r0 * edge);
502 if step.dot(h1 - h0) <= 0.0 {
503 ogeom_bail!(
504 Construction,
505 "the draft folds the wall onto itself inside the drafted \
506 window; refused; see docs/PARITY.md, offset.draft"
507 );
508 }
509 }
510 }
511
512 const ACROSS: usize = 9;
517 #[allow(clippy::cast_precision_loss)]
522 let span = (along_total - 1) as f64;
523 let xs: Vec<f64> = (0..along_total)
524 .map(|i| {
525 #[allow(clippy::cast_precision_loss)]
526 let x = i as f64 / span;
527 x
528 })
529 .collect();
530 let ss: Vec<f64> = (0..ACROSS)
531 .map(|j| {
532 #[allow(clippy::cast_precision_loss)]
533 let s = j as f64 / ((ACROSS - 1) as f64);
534 s
535 })
536 .collect();
537 let fit_target = (tol.confusion() * 1e3).max(1e-4);
538 let fitted = ogeom_geom::fit::fit_surface_sampled(
539 |s, x| {
540 let x = x * span;
541 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
542 let i = x.round() as usize;
543 #[allow(clippy::cast_precision_loss)]
544 let (hinge, ruling) = if (i as f64 - x).abs() <= 1e-12 * span && i < along_total {
545 stations[i]
546 } else {
547 station(x)?
548 };
549 Ok(hinge + ruling * (s_hi - (s_hi - s_lo) * s))
550 },
551 &ogeom_geom::fit::Sampling {
552 us: ss,
553 vs: xs,
554 between: (false, true),
555 closed_v: false,
556 most: 4096,
557 },
558 3,
559 fit_target,
560 tol,
561 )?;
562 if !fitted.met {
563 ogeom_bail!(
564 NotDone,
565 "the drafted wall's fit reached {} against a target of {fit_target}",
566 fitted.error
567 );
568 }
569 Ok(fitted.curve.into())
570}
571
572const HINGE_STATIONS: usize = 256;
574
575#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
582fn general_draft(
583 model: &Model,
584 face: &Shape,
585 surface: &SurfaceGeometry,
586 sign: f64,
587 neutral: Plane,
588 pull: Direction,
589 angle: f64,
590 tol: Tolerances,
591) -> OgeomResult<SurfaceGeometry> {
592 let n = neutral.normal().vector();
593 let mesh = ogeom_mesh::triangulate_face(
594 model,
595 face,
596 ogeom_mesh::Deflection {
597 chord: 0.05,
598 angular: 0.2,
599 ..ogeom_mesh::Deflection::default()
600 },
601 tol,
602 )?;
603 let extent = {
604 let b = mesh
605 .positions
606 .iter()
607 .fold(ogeom_math::Aabb::EMPTY, |acc, p| acc.with_point(*p));
608 match (b.low(), b.high()) {
609 (Some(lo), Some(hi)) => (hi - lo).magnitude(),
610 _ => ogeom_bail!(Construction, "the drafted face has no extent"),
611 }
612 };
613 if extent <= tol.confusion() {
614 ogeom_bail!(Construction, "the drafted face has no extent");
615 }
616
617 let on = tol.confusion() * 10.0;
623 let side: Vec<f64> = mesh
624 .positions
625 .iter()
626 .map(|p| neutral.signed_distance_to(*p))
627 .collect();
628 let mut segments: Vec<[((f64, f64), Point); 2]> = Vec::new();
629 for t in &mesh.triangles {
630 let mut ends: Vec<((f64, f64), Point)> = Vec::with_capacity(2);
631 for &corner in t {
632 let i = corner as usize;
633 if side[i].abs() <= on {
634 ends.push((mesh.parameters[i], mesh.positions[i]));
635 }
636 }
637 for k in 0..3 {
638 let (i, j) = (t[k] as usize, t[(k + 1) % 3] as usize);
639 let (a, b) = (side[i], side[j]);
640 if a.abs() <= on || b.abs() <= on || (a < 0.0) == (b < 0.0) {
641 continue;
642 }
643 let f = a / (a - b);
644 let (pa, pb) = (mesh.parameters[i], mesh.parameters[j]);
645 let (qa, qb) = (mesh.positions[i], mesh.positions[j]);
646 ends.push((
647 (pa.0 + (pb.0 - pa.0) * f, pa.1 + (pb.1 - pa.1) * f),
648 qa + (qb - qa) * f,
649 ));
650 }
651 ends.dedup_by(|a, b| a.1.distance(b.1) <= on);
652 if ends.len() == 2 && ends[0].1.distance(ends[1].1) > on {
653 segments.push([ends[0], ends[1]]);
654 }
655 }
656 if segments.is_empty() {
657 ogeom_bail!(
658 Construction,
659 "the neutral plane does not cross the drafted face; there is no \
660 hinge to turn about"
661 );
662 }
663 let same = |a: Point, b: Point| a.distance(b) <= on;
669 let mut chain: Vec<((f64, f64), Point)> = vec![segments[0][0], segments[0][1]];
670 let mut used = vec![false; segments.len()];
671 used[0] = true;
672 loop {
673 let tail = chain[chain.len() - 1].1;
674 let head = chain[0].1;
675 let mut grew = false;
676 for (k, seg) in segments.iter().enumerate() {
677 if used[k] {
678 continue;
679 }
680 if (same(seg[0].1, tail) && same(seg[1].1, head))
683 || (same(seg[1].1, tail) && same(seg[0].1, head))
684 {
685 chain.push(chain[0]);
686 used[k] = true;
687 grew = true;
688 break;
689 }
690 let covered = |p: Point| chain.iter().any(|c| same(c.1, p));
691 if covered(seg[0].1) && covered(seg[1].1) {
692 used[k] = true;
693 continue;
694 }
695 if same(seg[0].1, tail) {
696 chain.push(seg[1]);
697 } else if same(seg[1].1, tail) {
698 chain.push(seg[0]);
699 } else if same(seg[0].1, head) {
700 chain.insert(0, seg[1]);
701 } else if same(seg[1].1, head) {
702 chain.insert(0, seg[0]);
703 } else {
704 continue;
705 }
706 used[k] = true;
707 grew = true;
708 }
709 if !grew {
710 break;
711 }
712 }
713 if used.iter().any(|u| !u) {
714 ogeom_bail!(
715 Construction,
716 "the neutral plane crosses the drafted face more than once; \
717 there is no one hinge to turn about"
718 );
719 }
720 let closed = chain.len() > 3 && same(chain[0].1, chain[chain.len() - 1].1);
721 if closed {
722 chain.pop();
723 }
724 if chain.len() < 2 {
725 ogeom_bail!(
726 Construction,
727 "the neutral plane touches the drafted face at a point; there is \
728 no hinge to turn about"
729 );
730 }
731 let chain: Vec<(f64, f64)> = chain.into_iter().map(|c| c.0).collect();
732
733 let ((ua, ub), (va, vb)) = surface.domain();
739 let period = (
743 (surface.is_periodic_u() || surface.is_closed_u(tol)).then_some(ub - ua),
744 (surface.is_periodic_v() || surface.is_closed_v(tol)).then_some(vb - va),
745 );
746 let short = |a: f64, b: f64, period: Option<f64>| -> f64 {
747 let d = b - a;
748 match period {
749 Some(p) if d.abs() > p * 0.5 => d - p * d.signum(),
750 _ => d,
751 }
752 };
753 let chain: Vec<(f64, f64)> = {
754 let chain: Vec<(f64, f64)> = if closed {
758 let n = chain.len();
762 let mut exact: Option<(usize, (f64, f64))> = None;
763 for i in 0..n {
764 let (a, b) = (chain[i], chain[(i + 1) % n]);
765 let (da, db) = (short(ua, a.0, period.0), short(ua, b.0, period.0));
766 if da == 0.0 {
767 exact = Some((i, a));
768 break;
769 }
770 if (da < 0.0) != (db < 0.0) && (da - db).abs() > 0.0 {
771 let f = da / (da - db);
772 let dv = short(a.1, b.1, period.1);
773 exact = Some((i + 1, (ua, a.1 + dv * f)));
774 break;
775 }
776 }
777 let (start, inserted) = exact.unwrap_or_else(|| {
778 let mut best = (0usize, f64::INFINITY);
779 for (i, c) in chain.iter().enumerate() {
780 let d = short(ua, c.0, period.0).abs();
781 if d < best.1 {
782 best = (i, d);
783 }
784 }
785 (best.0, chain[best.0])
786 });
787 let mut rotated: Vec<(f64, f64)> = Vec::with_capacity(n + 1);
788 rotated.push(inserted);
789 for k in 0..n {
790 let c = chain[(start + k) % n];
791 if rotated.len() == 1 && c == inserted {
792 continue;
793 }
794 rotated.push(c);
795 }
796 rotated
797 } else {
798 chain
799 };
800 let pairs = if closed { chain.len() } else { chain.len() - 1 };
804 let mut lengths = Vec::with_capacity(pairs);
805 let mut total = 0.0;
806 for i in 0..pairs {
807 let (a, b) = (chain[i], chain[(i + 1) % chain.len()]);
808 let step = surface
809 .point_at(a.0, a.1, tol)?
810 .distance(surface.point_at(b.0, b.1, tol)?);
811 lengths.push(step);
812 total += step;
813 }
814 let count = if closed {
815 HINGE_STATIONS
816 } else {
817 HINGE_STATIONS + 1
818 };
819 let mut dense = Vec::with_capacity(count);
820 let (mut pair, mut walked) = (0usize, 0.0_f64);
821 for k in 0..count {
822 #[allow(clippy::cast_precision_loss)]
823 let target = total * k as f64 / HINGE_STATIONS as f64;
824 while pair + 1 < pairs && walked + lengths[pair] < target {
825 walked += lengths[pair];
826 pair += 1;
827 }
828 let (a, b) = (chain[pair], chain[(pair + 1) % chain.len()]);
829 let (du, dv) = (short(a.0, b.0, period.0), short(a.1, b.1, period.1));
830 let f = if lengths[pair] > 0.0 {
831 ((target - walked) / lengths[pair]).clamp(0.0, 1.0)
832 } else {
833 0.0
834 };
835 dense.push((a.0 + du * f, a.1 + dv * f));
836 }
837 dense
838 };
839
840 let on_hinge = |(mut u, mut v): (f64, f64),
847 pinned: bool|
848 -> OgeomResult<((f64, f64), Point, Vector, Vector)> {
849 for _ in 0..8 {
850 let p = surface.point_at(u, v, tol)?;
851 let f = neutral.signed_distance_to(p);
852 if f.abs() <= tol.confusion() * 1e-2 {
853 break;
854 }
855 let (du, dv) = surface.d1_at(u, v, tol)?;
856 let g = (if pinned { 0.0 } else { n.dot(du) }, n.dot(dv));
857 let g2 = g.0 * g.0 + g.1 * g.1;
858 if g2 <= 0.0 {
859 break;
860 }
861 u -= f * g.0 / g2;
862 v -= f * g.1 / g2;
863 }
864 let p = surface.point_at(u, v, tol)?;
865 let (du, dv) = surface.d1_at(u, v, tol)?;
866 let raw = du.cross(dv);
867 if raw.magnitude() <= tol.confusion() {
868 ogeom_bail!(Construction, "the drafted face has no normal on its hinge");
869 }
870 let outward = raw / raw.magnitude() * sign;
871 let t = outward.cross(n);
873 if t.magnitude() <= tol.angular() {
874 ogeom_bail!(
875 Construction,
876 "the neutral plane is tangent to the drafted face; there is no \
877 hinge to turn about"
878 );
879 }
880 Ok(((u, v), p, t / t.magnitude(), outward))
881 };
882 let mut feet: Vec<(f64, f64)> = Vec::with_capacity(chain.len());
883 let mut hinges: Vec<Point> = Vec::with_capacity(chain.len());
884 let mut tangents: Vec<Vector> = Vec::with_capacity(chain.len());
885 let mut outwards: Vec<Vector> = Vec::with_capacity(chain.len());
886 for (k, &at) in chain.iter().enumerate() {
887 let (foot, p, mut t, outward) = on_hinge(at, closed && k == 0)?;
890 let next = chain[(k + 1) % chain.len()];
892 let prev = chain[(k + chain.len() - 1) % chain.len()];
893 let ahead =
894 surface.point_at(next.0, next.1, tol)? - surface.point_at(prev.0, prev.1, tol)?;
895 if t.dot(ahead) < 0.0 {
896 t = -t;
897 }
898 feet.push(foot);
899 hinges.push(p);
900 tangents.push(t);
901 outwards.push(outward);
902 }
903
904 let m = hinges.len() / 2;
908 let axis_m = ogeom_math::Axis::new(hinges[m], Direction::new(tangents[m], tol)?);
909 let mut leaning = 1.0;
910 let mut best = f64::NEG_INFINITY;
911 for sense in [1.0_f64, -1.0] {
912 let turn = Transform::rotation(axis_m, angle.abs() * sense);
913 let lean = turn.apply_vector(outwards[m]).dot(pull.vector());
914 if lean > best {
915 best = lean;
916 leaning = sense;
917 }
918 }
919 let theta = angle * leaning;
920
921 let mut rulings: Vec<Vector> = Vec::with_capacity(hinges.len());
923 for (hinge, tangent) in hinges.iter().zip(&tangents) {
924 let turn = Transform::rotation(
925 ogeom_math::Axis::new(*hinge, Direction::new(*tangent, tol)?),
926 theta,
927 );
928 rulings.push(turn.apply_vector(pull.vector()));
929 }
930 let (mut s_lo, mut s_hi) = (f64::INFINITY, f64::NEG_INFINITY);
937 let (mut h_lo, mut h_hi) = (f64::INFINITY, f64::NEG_INFINITY);
938 for h in &hinges {
939 let s = h.to_vector().dot(pull.vector());
940 h_lo = h_lo.min(s);
941 h_hi = h_hi.max(s);
942 }
943 for p in &mesh.positions {
944 let s = p.to_vector().dot(pull.vector());
945 s_lo = s_lo.min(s - h_hi);
946 s_hi = s_hi.max(s - h_lo);
947 }
948 let grow = extent.mul_add(0.5, 1.0) * angle.abs().tan() + tol.confusion();
949 let (s_lo, s_hi) = (s_lo - grow, s_hi + grow);
950 let mut lead = 0;
952 if !closed {
953 let extend = |hinges: &mut Vec<Point>, rulings: &mut Vec<Vector>, front: bool| -> usize {
956 let (i0, i1) = if front {
957 (0, 1)
958 } else {
959 (hinges.len() - 1, hinges.len() - 2)
960 };
961 let d1 = hinges[i0] - hinges[i1];
962 let steps = (grow / d1.magnitude().max(tol.confusion())).ceil().max(2.0);
963 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
964 let steps = (steps as usize).min(16);
965 for k in 1..=steps {
966 #[allow(clippy::cast_precision_loss)]
967 let station = (hinges[i0] + d1 * k as f64, rulings[i0]);
968 if front {
969 hinges.insert(0, station.0);
970 rulings.insert(0, station.1);
971 } else {
972 hinges.push(station.0);
973 rulings.push(station.1);
974 }
975 }
976 steps
977 };
978 lead = extend(&mut hinges, &mut rulings, true);
979 extend(&mut hinges, &mut rulings, false);
980 } else {
981 hinges.push(hinges[0]);
982 rulings.push(rulings[0]);
983 }
984 for edge in [s_lo, s_hi] {
985 for i in 0..hinges.len() - 1 {
986 let step = (hinges[i + 1] + rulings[i + 1] * edge) - (hinges[i] + rulings[i] * edge);
987 if step.dot(hinges[i + 1] - hinges[i]) <= 0.0 {
988 ogeom_bail!(
989 Construction,
990 "the draft folds the wall onto itself inside the drafted \
991 window; refused; see docs/PARITY.md, offset.draft"
992 );
993 }
994 }
995 }
996 let params: Vec<f64> = {
1011 let mut out = Vec::with_capacity(hinges.len());
1012 let mut total = 0.0;
1013 out.push(0.0);
1014 for pair in hinges.windows(2) {
1015 total += pair[0].distance(pair[1]);
1016 out.push(total);
1017 }
1018 if total > 0.0 {
1019 for t in &mut out {
1020 *t /= total;
1021 }
1022 }
1023 if let Some(last) = out.last_mut() {
1024 *last = 1.0;
1025 }
1026 out
1027 };
1028 let stations = feet.len();
1033 let wrap = |x: f64, lo: f64, period: Option<f64>| -> f64 {
1034 period.map_or(x, |p| lo + (x - lo).rem_euclid(p))
1035 };
1036 let exact_at = |at: f64| -> OgeomResult<(Point, Vector)> {
1037 let i = params
1038 .partition_point(|p| *p <= at)
1039 .saturating_sub(1)
1040 .min(params.len() - 2);
1041 let f = (at - params[i]) / (params[i + 1] - params[i]);
1042 if f <= 0.0 {
1043 return Ok((hinges[i], rulings[i]));
1044 }
1045 let (k0, k1) = (i.wrapping_sub(lead), (i + 1).wrapping_sub(lead));
1046 if k0 >= stations || k1 > stations || (!closed && k1 == stations) {
1047 return Ok((hinges[i] + (hinges[i + 1] - hinges[i]) * f, rulings[i]));
1048 }
1049 let (a, b) = (feet[k0], feet[k1 % stations]);
1050 let (mut u, mut v) = (
1051 wrap(a.0 + short(a.0, b.0, period.0) * f, ua, period.0),
1052 wrap(a.1 + short(a.1, b.1, period.1) * f, va, period.1),
1053 );
1054 let (from, chord) = (hinges[i], hinges[i + 1] - hinges[i]);
1059 for _ in 0..8 {
1060 let q = surface.point_at(u, v, tol)?;
1061 let g = (
1062 neutral.signed_distance_to(q),
1063 (q - from).dot(chord) - f * chord.dot(chord),
1064 );
1065 if g.0.abs() <= tol.confusion() * 1e-2
1066 && g.1.abs() <= tol.confusion() * 1e-2 * chord.magnitude()
1067 {
1068 break;
1069 }
1070 let (du, dv) = surface.d1_at(u, v, tol)?;
1071 let (a11, a12, a21, a22) = (n.dot(du), n.dot(dv), chord.dot(du), chord.dot(dv));
1072 let det = a11 * a22 - a12 * a21;
1073 if det.abs() <= f64::MIN_POSITIVE {
1074 break;
1075 }
1076 u = wrap(u - (g.0 * a22 - a12 * g.1) / det, ua, period.0);
1077 v = wrap(v - (a11 * g.1 - a21 * g.0) / det, va, period.1);
1078 }
1079 let (_, p, t, _) = on_hinge((u, v), false)?;
1080 let t = if t.dot(tangents[k0]) < 0.0 { -t } else { t };
1081 let turn = Transform::rotation(ogeom_math::Axis::new(p, Direction::new(t, tol)?), theta);
1082 Ok((p, turn.apply_vector(pull.vector())))
1083 };
1084 const SEAM_BLEND: f64 = 1.0 / 32.0;
1092 let seam = if closed {
1093 let leave = exact_at(params[1] * 1e-9)?.1;
1094 let back = exact_at(1.0 - (1.0 - params[params.len() - 2]) * 1e-9)?.1;
1095 let mean = leave + back;
1096 Some((leave, back, mean / mean.magnitude()))
1097 } else {
1098 None
1099 };
1100 let ruled_at = |at: f64| -> OgeomResult<(Point, Vector)> {
1101 let Some((leave, back, mean)) = seam else {
1102 return exact_at(at);
1103 };
1104 if at <= 0.0 || at >= 1.0 {
1105 return Ok((hinges[0], mean));
1106 }
1107 let (p, r) = exact_at(at)?;
1108 let left = |x: f64| {
1111 let x = x.clamp(0.0, 1.0);
1112 1.0 - x * x * 2.0f64.mul_add(-x, 3.0)
1113 };
1114 let r = if at < SEAM_BLEND {
1115 r + (mean - leave) * left(at / SEAM_BLEND)
1116 } else if at > 1.0 - SEAM_BLEND {
1117 r + (mean - back) * left((1.0 - at) / SEAM_BLEND)
1118 } else {
1119 return Ok((p, r));
1120 };
1121 Ok((p, r / r.magnitude()))
1122 };
1123 let fit_target = (tol.confusion() * 1e3).max(1e-4);
1127 let rows = ogeom_geom::fit::fit_surface_sampled(
1128 |at, side| {
1129 let (h, r) = ruled_at(at)?;
1130 Ok(h + r * if side < 0.5 { s_lo } else { s_hi })
1131 },
1132 &ogeom_geom::fit::Sampling {
1133 us: params.clone(),
1134 vs: vec![0.0, 1.0],
1135 between: (true, false),
1136 closed_v: false,
1137 most: 1024,
1138 },
1139 3,
1140 fit_target,
1141 tol,
1142 )?;
1143 if !rows.met {
1144 ogeom_bail!(
1145 NotDone,
1146 "the drafted wall's border rows reached {} against a target of {fit_target}",
1147 rows.error
1148 );
1149 }
1150 let fitted = rows.curve;
1151 let (k, l) = (fitted.grid().u_count(), fitted.grid().v_count());
1152 if l != 2 || fitted.v_knots().degree() != 1 {
1153 ogeom_bail!(
1154 Construction,
1155 "the drafted wall's border rows did not fit as a ruled surface"
1156 );
1157 }
1158 let corners: Vec<Point> = fitted.grid().points().iter().map(|w| w.point()).collect();
1159 let (lc, hc): (Vec<Point>, Vec<Point>) =
1160 (0..k).map(|i| (corners[i * 2], corners[i * 2 + 1])).unzip();
1161 let mid = {
1166 let (ud, _) = fitted.domain();
1167 f64::midpoint(ud.0, ud.1)
1168 };
1169 let (low_mid, high_mid) = (
1170 fitted.point_at(mid, 0.0, tol)?,
1171 fitted.point_at(mid, 1.0, tol)?,
1172 );
1173 let hinge_mid = low_mid + (high_mid - low_mid) * (-s_lo / (s_hi - s_lo));
1174 let across = fitted.d1_at(mid, 0.0, tol)?.0;
1175 let old_normal = {
1176 let foot = ogeom_algo::project_on_surface(surface, hinge_mid, 16, tol)?;
1177 let (u, v) = foot.parameters;
1178 surface.normal_at(u, v, tol)?.vector()
1179 };
1180 let agrees = across.cross(high_mid - low_mid).dot(old_normal) >= 0.0;
1183 let (first, second, v_range) = if agrees {
1184 (&lc, &hc, (s_lo, s_hi))
1185 } else {
1186 (&hc, &lc, (-s_hi, -s_lo))
1187 };
1188 let mut net: Vec<Point> = Vec::with_capacity(k * 2);
1189 for (a, b) in first.iter().zip(second) {
1190 net.push(*a);
1191 net.push(*b);
1192 }
1193 let grid = ogeom_math::ControlGrid::new(net, k, 2)?;
1194 let u_knots = if closed {
1198 fitted.u_knots().reparameterized(ua, ub)?
1199 } else {
1200 fitted.u_knots().clone()
1201 };
1202 let v_knots =
1203 ogeom_math::KnotVector::clamped_uniform(1, 2)?.reparameterized(v_range.0, v_range.1)?;
1204 Ok(ogeom_geom::BSplineSurface::new(u_knots, v_knots, &grid, tol)?.into())
1205}
1206
1207fn outward_sign(
1216 model: &Model,
1217 solid: &Shape,
1218 face: &Shape,
1219 surface: &SurfaceGeometry,
1220 tol: Tolerances,
1221) -> OgeomResult<f64> {
1222 use ogeom_algo::Containment;
1223 let mesh = ogeom_mesh::triangulate_face(model, face, ogeom_mesh::Deflection::default(), tol)?;
1227 let mut at = None;
1228 let mut largest = 0.0_f64;
1229 for t in &mesh.triangles {
1230 let [a, b, c] = [
1231 mesh.positions[t[0] as usize],
1232 mesh.positions[t[1] as usize],
1233 mesh.positions[t[2] as usize],
1234 ];
1235 let area = (b - a).cross(c - a).magnitude();
1236 if area > largest {
1237 largest = area;
1238 let params = [
1239 mesh.parameters[t[0] as usize],
1240 mesh.parameters[t[1] as usize],
1241 mesh.parameters[t[2] as usize],
1242 ];
1243 at = Some((
1244 (params[0].0 + params[1].0 + params[2].0) / 3.0,
1245 (params[0].1 + params[1].1 + params[2].1) / 3.0,
1246 ));
1247 }
1248 }
1249 let Some((um, vm)) = at else {
1250 ogeom_bail!(Construction, "the drafted face has no interior to probe");
1251 };
1252 let p = surface.point_at(um, vm, tol)?;
1253 let (du, dv) = surface.d1_at(um, vm, tol)?;
1254 let n = du.cross(dv);
1255 let m = n.magnitude();
1256 if m <= tol.confusion() {
1257 ogeom_bail!(Construction, "the face has no normal at its midpoint");
1258 }
1259 let n = n / m;
1260 let scale = largest.sqrt().max(tol.confusion() * 1e3);
1261 for eps_scale in [1e-3, 1e-2, 5e-2] {
1262 let eps = scale * eps_scale;
1263 let deflection = ogeom_mesh::Deflection {
1264 chord: (eps * 0.1).max(1e-4),
1265 ..ogeom_mesh::Deflection::default()
1266 };
1267 let ahead = ogeom_algo::classify_in_solid(model, solid, p + n * eps, deflection, tol)?;
1268 let behind = ogeom_algo::classify_in_solid(model, solid, p - n * eps, deflection, tol)?;
1269 match (ahead, behind) {
1270 (Containment::Out, Containment::In) => return Ok(1.0),
1271 (Containment::In, Containment::Out) => return Ok(-1.0),
1272 _ => {}
1273 }
1274 }
1275 ogeom_bail!(
1276 Construction,
1277 "cannot read which side of the drafted face holds material; the wall is thinner than the probe can resolve"
1278 )
1279}
1280
1281fn meet(a: Plane, b: Plane, along: Vector, tol: Tolerances) -> OgeomResult<Point> {
1283 let rows = [a.normal().vector(), b.normal().vector(), along];
1284 let rhs = [
1285 rows[0].dot(a.origin().to_vector()),
1286 rows[1].dot(b.origin().to_vector()),
1287 along.dot(Point::midpoint(a.origin(), b.origin()).to_vector()),
1288 ];
1289 let det = rows[0].dot(rows[1].cross(rows[2]));
1290 if det.abs() <= tol.confusion() {
1291 ogeom_bail!(Construction, "the two planes do not meet in a line");
1292 }
1293 Ok(Point::ORIGIN
1294 + (rows[1].cross(rows[2]) * rhs[0]
1295 + rows[2].cross(rows[0]) * rhs[1]
1296 + rows[0].cross(rows[1]) * rhs[2])
1297 / det)
1298}