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 mut grew = false;
674 for (k, seg) in segments.iter().enumerate() {
675 if used[k] {
676 continue;
677 }
678 let tail = chain[chain.len() - 1].1;
682 let head = chain[0].1;
683 if (same(seg[0].1, tail) && same(seg[1].1, head))
686 || (same(seg[1].1, tail) && same(seg[0].1, head))
687 {
688 chain.push(chain[0]);
689 used[k] = true;
690 grew = true;
691 break;
692 }
693 let covered = |p: Point| chain.iter().any(|c| same(c.1, p));
694 if covered(seg[0].1) && covered(seg[1].1) {
695 used[k] = true;
696 continue;
697 }
698 if same(seg[0].1, tail) {
699 chain.push(seg[1]);
700 } else if same(seg[1].1, tail) {
701 chain.push(seg[0]);
702 } else if same(seg[0].1, head) {
703 chain.insert(0, seg[1]);
704 } else if same(seg[1].1, head) {
705 chain.insert(0, seg[0]);
706 } else {
707 continue;
708 }
709 used[k] = true;
710 grew = true;
711 }
712 if !grew {
713 break;
714 }
715 }
716 if used.iter().any(|u| !u) {
717 ogeom_bail!(
718 Construction,
719 "the neutral plane crosses the drafted face more than once; \
720 there is no one hinge to turn about"
721 );
722 }
723 let closed = chain.len() > 3 && same(chain[0].1, chain[chain.len() - 1].1);
724 if closed {
725 chain.pop();
726 }
727 if chain.len() < 2 {
728 ogeom_bail!(
729 Construction,
730 "the neutral plane touches the drafted face at a point; there is \
731 no hinge to turn about"
732 );
733 }
734 let chain: Vec<(f64, f64)> = chain.into_iter().map(|c| c.0).collect();
735
736 let ((ua, ub), (va, vb)) = surface.domain();
742 let period = (
746 (surface.is_periodic_u() || surface.is_closed_u(tol)).then_some(ub - ua),
747 (surface.is_periodic_v() || surface.is_closed_v(tol)).then_some(vb - va),
748 );
749 let short = |a: f64, b: f64, period: Option<f64>| -> f64 {
750 let d = b - a;
751 match period {
752 Some(p) if d.abs() > p * 0.5 => d - p * d.signum(),
753 _ => d,
754 }
755 };
756 let chain: Vec<(f64, f64)> = {
757 let chain: Vec<(f64, f64)> = if closed {
761 let n = chain.len();
765 let mut exact: Option<(usize, (f64, f64))> = None;
766 for i in 0..n {
767 let (a, b) = (chain[i], chain[(i + 1) % n]);
768 let da = short(ua, a.0, period.0);
772 let db = da + short(a.0, b.0, period.0);
773 if da == 0.0 {
774 exact = Some((i, a));
775 break;
776 }
777 if (da < 0.0) != (db < 0.0) && (da - db).abs() > 0.0 {
778 let f = da / (da - db);
779 let dv = short(a.1, b.1, period.1);
780 exact = Some((i + 1, (ua, a.1 + dv * f)));
781 break;
782 }
783 }
784 let (start, inserted) = exact.unwrap_or_else(|| {
785 let mut best = (0usize, f64::INFINITY);
786 for (i, c) in chain.iter().enumerate() {
787 let d = short(ua, c.0, period.0).abs();
788 if d < best.1 {
789 best = (i, d);
790 }
791 }
792 (best.0, chain[best.0])
793 });
794 let mut rotated: Vec<(f64, f64)> = Vec::with_capacity(n + 1);
795 rotated.push(inserted);
796 for k in 0..n {
797 let c = chain[(start + k) % n];
798 if rotated.len() == 1 && c == inserted {
799 continue;
800 }
801 rotated.push(c);
802 }
803 rotated
804 } else {
805 chain
806 };
807 let pairs = if closed { chain.len() } else { chain.len() - 1 };
811 let mut lengths = Vec::with_capacity(pairs);
812 let mut total = 0.0;
813 for i in 0..pairs {
814 let (a, b) = (chain[i], chain[(i + 1) % chain.len()]);
815 let step = surface
816 .point_at(a.0, a.1, tol)?
817 .distance(surface.point_at(b.0, b.1, tol)?);
818 lengths.push(step);
819 total += step;
820 }
821 let count = if closed {
822 HINGE_STATIONS
823 } else {
824 HINGE_STATIONS + 1
825 };
826 let mut dense = Vec::with_capacity(count);
827 let (mut pair, mut walked) = (0usize, 0.0_f64);
828 for k in 0..count {
829 #[allow(clippy::cast_precision_loss)]
830 let target = total * k as f64 / HINGE_STATIONS as f64;
831 while pair + 1 < pairs && walked + lengths[pair] < target {
832 walked += lengths[pair];
833 pair += 1;
834 }
835 let (a, b) = (chain[pair], chain[(pair + 1) % chain.len()]);
836 let (du, dv) = (short(a.0, b.0, period.0), short(a.1, b.1, period.1));
837 let f = if lengths[pair] > 0.0 {
838 ((target - walked) / lengths[pair]).clamp(0.0, 1.0)
839 } else {
840 0.0
841 };
842 dense.push((a.0 + du * f, a.1 + dv * f));
843 }
844 dense
845 };
846
847 let on_hinge = |(mut u, mut v): (f64, f64),
854 pinned: bool|
855 -> OgeomResult<((f64, f64), Point, Vector, Vector)> {
856 for _ in 0..8 {
857 let p = surface.point_at(u, v, tol)?;
858 let f = neutral.signed_distance_to(p);
859 if f.abs() <= tol.confusion() * 1e-2 {
860 break;
861 }
862 let (du, dv) = surface.d1_at(u, v, tol)?;
863 let g = (if pinned { 0.0 } else { n.dot(du) }, n.dot(dv));
864 let g2 = g.0 * g.0 + g.1 * g.1;
865 if g2 <= 0.0 {
866 break;
867 }
868 u -= f * g.0 / g2;
869 v -= f * g.1 / g2;
870 }
871 let p = surface.point_at(u, v, tol)?;
872 let (du, dv) = surface.d1_at(u, v, tol)?;
873 let raw = du.cross(dv);
874 if raw.magnitude() <= tol.confusion() {
875 ogeom_bail!(Construction, "the drafted face has no normal on its hinge");
876 }
877 let outward = raw / raw.magnitude() * sign;
878 let t = outward.cross(n);
880 if t.magnitude() <= tol.angular() {
881 ogeom_bail!(
882 Construction,
883 "the neutral plane is tangent to the drafted face; there is no \
884 hinge to turn about"
885 );
886 }
887 Ok(((u, v), p, t / t.magnitude(), outward))
888 };
889 let mut feet: Vec<(f64, f64)> = Vec::with_capacity(chain.len());
890 let mut hinges: Vec<Point> = Vec::with_capacity(chain.len());
891 let mut tangents: Vec<Vector> = Vec::with_capacity(chain.len());
892 let mut outwards: Vec<Vector> = Vec::with_capacity(chain.len());
893 for (k, &at) in chain.iter().enumerate() {
894 let (foot, p, mut t, outward) = on_hinge(at, closed && k == 0)?;
897 let next = chain[(k + 1) % chain.len()];
899 let prev = chain[(k + chain.len() - 1) % chain.len()];
900 let ahead =
901 surface.point_at(next.0, next.1, tol)? - surface.point_at(prev.0, prev.1, tol)?;
902 if t.dot(ahead) < 0.0 {
903 t = -t;
904 }
905 feet.push(foot);
906 hinges.push(p);
907 tangents.push(t);
908 outwards.push(outward);
909 }
910
911 let m = hinges.len() / 2;
915 let axis_m = ogeom_math::Axis::new(hinges[m], Direction::new(tangents[m], tol)?);
916 let mut leaning = 1.0;
917 let mut best = f64::NEG_INFINITY;
918 for sense in [1.0_f64, -1.0] {
919 let turn = Transform::rotation(axis_m, angle.abs() * sense);
920 let lean = turn.apply_vector(outwards[m]).dot(pull.vector());
921 if lean > best {
922 best = lean;
923 leaning = sense;
924 }
925 }
926 let theta = angle * leaning;
927
928 let mut rulings: Vec<Vector> = Vec::with_capacity(hinges.len());
930 for (hinge, tangent) in hinges.iter().zip(&tangents) {
931 let turn = Transform::rotation(
932 ogeom_math::Axis::new(*hinge, Direction::new(*tangent, tol)?),
933 theta,
934 );
935 rulings.push(turn.apply_vector(pull.vector()));
936 }
937 let (mut s_lo, mut s_hi) = (f64::INFINITY, f64::NEG_INFINITY);
944 let (mut h_lo, mut h_hi) = (f64::INFINITY, f64::NEG_INFINITY);
945 for h in &hinges {
946 let s = h.to_vector().dot(pull.vector());
947 h_lo = h_lo.min(s);
948 h_hi = h_hi.max(s);
949 }
950 for p in &mesh.positions {
951 let s = p.to_vector().dot(pull.vector());
952 s_lo = s_lo.min(s - h_hi);
953 s_hi = s_hi.max(s - h_lo);
954 }
955 let grow = extent.mul_add(0.5, 1.0) * angle.abs().tan() + tol.confusion();
956 let (s_lo, s_hi) = (s_lo - grow, s_hi + grow);
957 let mut lead = 0;
959 if !closed {
960 let extend = |hinges: &mut Vec<Point>, rulings: &mut Vec<Vector>, front: bool| -> usize {
963 let (i0, i1) = if front {
964 (0, 1)
965 } else {
966 (hinges.len() - 1, hinges.len() - 2)
967 };
968 let d1 = hinges[i0] - hinges[i1];
969 let steps = (grow / d1.magnitude().max(tol.confusion())).ceil().max(2.0);
970 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
971 let steps = (steps as usize).min(16);
972 for k in 1..=steps {
973 #[allow(clippy::cast_precision_loss)]
974 let station = (hinges[i0] + d1 * k as f64, rulings[i0]);
975 if front {
976 hinges.insert(0, station.0);
977 rulings.insert(0, station.1);
978 } else {
979 hinges.push(station.0);
980 rulings.push(station.1);
981 }
982 }
983 steps
984 };
985 lead = extend(&mut hinges, &mut rulings, true);
986 extend(&mut hinges, &mut rulings, false);
987 } else {
988 hinges.push(hinges[0]);
989 rulings.push(rulings[0]);
990 }
991 for edge in [s_lo, s_hi] {
992 for i in 0..hinges.len() - 1 {
993 let step = (hinges[i + 1] + rulings[i + 1] * edge) - (hinges[i] + rulings[i] * edge);
994 if step.dot(hinges[i + 1] - hinges[i]) <= 0.0 {
995 ogeom_bail!(
996 Construction,
997 "the draft folds the wall onto itself inside the drafted \
998 window; refused; see docs/PARITY.md, offset.draft"
999 );
1000 }
1001 }
1002 }
1003 let params: Vec<f64> = {
1018 let mut out = Vec::with_capacity(hinges.len());
1019 let mut total = 0.0;
1020 out.push(0.0);
1021 for pair in hinges.windows(2) {
1022 total += pair[0].distance(pair[1]);
1023 out.push(total);
1024 }
1025 if total > 0.0 {
1026 for t in &mut out {
1027 *t /= total;
1028 }
1029 }
1030 if let Some(last) = out.last_mut() {
1031 *last = 1.0;
1032 }
1033 out
1034 };
1035 let stations = feet.len();
1040 let wrap = |x: f64, lo: f64, period: Option<f64>| -> f64 {
1041 period.map_or(x, |p| lo + (x - lo).rem_euclid(p))
1042 };
1043 let exact_at = |at: f64| -> OgeomResult<(Point, Vector)> {
1044 let i = params
1045 .partition_point(|p| *p <= at)
1046 .saturating_sub(1)
1047 .min(params.len() - 2);
1048 let f = (at - params[i]) / (params[i + 1] - params[i]);
1049 if f <= 0.0 {
1050 return Ok((hinges[i], rulings[i]));
1051 }
1052 let (k0, k1) = (i.wrapping_sub(lead), (i + 1).wrapping_sub(lead));
1053 if k0 >= stations || k1 > stations || (!closed && k1 == stations) {
1054 return Ok((hinges[i] + (hinges[i + 1] - hinges[i]) * f, rulings[i]));
1055 }
1056 let (a, b) = (feet[k0], feet[k1 % stations]);
1057 let (mut u, mut v) = (
1058 wrap(a.0 + short(a.0, b.0, period.0) * f, ua, period.0),
1059 wrap(a.1 + short(a.1, b.1, period.1) * f, va, period.1),
1060 );
1061 let (from, chord) = (hinges[i], hinges[i + 1] - hinges[i]);
1066 for _ in 0..8 {
1067 let q = surface.point_at(u, v, tol)?;
1068 let g = (
1069 neutral.signed_distance_to(q),
1070 (q - from).dot(chord) - f * chord.dot(chord),
1071 );
1072 if g.0.abs() <= tol.confusion() * 1e-2
1073 && g.1.abs() <= tol.confusion() * 1e-2 * chord.magnitude()
1074 {
1075 break;
1076 }
1077 let (du, dv) = surface.d1_at(u, v, tol)?;
1078 let (a11, a12, a21, a22) = (n.dot(du), n.dot(dv), chord.dot(du), chord.dot(dv));
1079 let det = a11 * a22 - a12 * a21;
1080 if det.abs() <= f64::MIN_POSITIVE {
1081 break;
1082 }
1083 u = wrap(u - (g.0 * a22 - a12 * g.1) / det, ua, period.0);
1084 v = wrap(v - (a11 * g.1 - a21 * g.0) / det, va, period.1);
1085 }
1086 let (_, p, t, _) = on_hinge((u, v), false)?;
1087 let t = if t.dot(tangents[k0]) < 0.0 { -t } else { t };
1088 let turn = Transform::rotation(ogeom_math::Axis::new(p, Direction::new(t, tol)?), theta);
1089 Ok((p, turn.apply_vector(pull.vector())))
1090 };
1091 const SEAM_BLEND: f64 = 1.0 / 32.0;
1103 let seam = if closed {
1104 let leave = exact_at(params[1] * 1e-9)?.1;
1105 let back = exact_at(1.0 - (1.0 - params[params.len() - 2]) * 1e-9)?.1;
1106 let mean = leave + back;
1107 Some((leave, back, mean / mean.magnitude()))
1108 } else {
1109 None
1110 };
1111 let ruled_at = |at: f64| -> OgeomResult<(Point, Vector)> {
1112 let Some((leave, back, mean)) = seam else {
1113 return exact_at(at);
1114 };
1115 if at <= 0.0 || at >= 1.0 {
1116 return Ok((hinges[0], mean));
1117 }
1118 let (p, r) = exact_at(at)?;
1119 let left = |x: f64| {
1122 let x = x.clamp(0.0, 1.0);
1123 1.0 - x * x * 2.0f64.mul_add(-x, 3.0)
1124 };
1125 let r = if at < SEAM_BLEND {
1126 r + (mean - leave) * left(at / SEAM_BLEND)
1127 } else if at > 1.0 - SEAM_BLEND {
1128 r + (mean - back) * left((1.0 - at) / SEAM_BLEND)
1129 } else {
1130 return Ok((p, r));
1131 };
1132 Ok((p, r / r.magnitude()))
1133 };
1134 let fit_target = (tol.confusion() * 1e3).max(1e-4);
1138 let rows = ogeom_geom::fit::fit_surface_sampled(
1139 |at, side| {
1140 let (h, r) = ruled_at(at)?;
1141 Ok(h + r * if side < 0.5 { s_lo } else { s_hi })
1142 },
1143 &ogeom_geom::fit::Sampling {
1144 us: params.clone(),
1145 vs: vec![0.0, 1.0],
1146 between: (true, false),
1147 closed_v: false,
1148 most: 1024,
1149 },
1150 3,
1151 fit_target,
1152 tol,
1153 )?;
1154 if !rows.met {
1155 ogeom_bail!(
1156 NotDone,
1157 "the drafted wall's border rows reached {} against a target of {fit_target}",
1158 rows.error
1159 );
1160 }
1161 let fitted = rows.curve;
1162 let (k, l) = (fitted.grid().u_count(), fitted.grid().v_count());
1163 if l != 2 || fitted.v_knots().degree() != 1 {
1164 ogeom_bail!(
1165 Construction,
1166 "the drafted wall's border rows did not fit as a ruled surface"
1167 );
1168 }
1169 let corners: Vec<Point> = fitted.grid().points().iter().map(|w| w.point()).collect();
1170 let (lc, hc): (Vec<Point>, Vec<Point>) =
1171 (0..k).map(|i| (corners[i * 2], corners[i * 2 + 1])).unzip();
1172 let mid = {
1177 let (ud, _) = fitted.domain();
1178 f64::midpoint(ud.0, ud.1)
1179 };
1180 let (low_mid, high_mid) = (
1181 fitted.point_at(mid, 0.0, tol)?,
1182 fitted.point_at(mid, 1.0, tol)?,
1183 );
1184 let hinge_mid = low_mid + (high_mid - low_mid) * (-s_lo / (s_hi - s_lo));
1185 let across = fitted.d1_at(mid, 0.0, tol)?.0;
1186 let old_normal = {
1187 let foot = ogeom_algo::project_on_surface(surface, hinge_mid, 16, tol)?;
1188 let (u, v) = foot.parameters;
1189 surface.normal_at(u, v, tol)?.vector()
1190 };
1191 let agrees = across.cross(high_mid - low_mid).dot(old_normal) >= 0.0;
1194 let (first, second, v_range) = if agrees {
1195 (&lc, &hc, (s_lo, s_hi))
1196 } else {
1197 (&hc, &lc, (-s_hi, -s_lo))
1198 };
1199 let mut net: Vec<Point> = Vec::with_capacity(k * 2);
1200 for (a, b) in first.iter().zip(second) {
1201 net.push(*a);
1202 net.push(*b);
1203 }
1204 let grid = ogeom_math::ControlGrid::new(net, k, 2)?;
1205 let u_knots = if closed {
1209 fitted.u_knots().reparameterized(ua, ub)?
1210 } else {
1211 fitted.u_knots().clone()
1212 };
1213 let v_knots =
1214 ogeom_math::KnotVector::clamped_uniform(1, 2)?.reparameterized(v_range.0, v_range.1)?;
1215 Ok(ogeom_geom::BSplineSurface::new(u_knots, v_knots, &grid, tol)?.into())
1216}
1217
1218fn outward_sign(
1227 model: &Model,
1228 solid: &Shape,
1229 face: &Shape,
1230 surface: &SurfaceGeometry,
1231 tol: Tolerances,
1232) -> OgeomResult<f64> {
1233 use ogeom_algo::Containment;
1234 let mesh = ogeom_mesh::triangulate_face(model, face, ogeom_mesh::Deflection::default(), tol)?;
1238 let mut at = None;
1239 let mut largest = 0.0_f64;
1240 for t in &mesh.triangles {
1241 let [a, b, c] = [
1242 mesh.positions[t[0] as usize],
1243 mesh.positions[t[1] as usize],
1244 mesh.positions[t[2] as usize],
1245 ];
1246 let area = (b - a).cross(c - a).magnitude();
1247 if area > largest {
1248 largest = area;
1249 let params = [
1250 mesh.parameters[t[0] as usize],
1251 mesh.parameters[t[1] as usize],
1252 mesh.parameters[t[2] as usize],
1253 ];
1254 at = Some((
1255 (params[0].0 + params[1].0 + params[2].0) / 3.0,
1256 (params[0].1 + params[1].1 + params[2].1) / 3.0,
1257 ));
1258 }
1259 }
1260 let Some((um, vm)) = at else {
1261 ogeom_bail!(Construction, "the drafted face has no interior to probe");
1262 };
1263 let p = surface.point_at(um, vm, tol)?;
1264 let (du, dv) = surface.d1_at(um, vm, tol)?;
1265 let n = du.cross(dv);
1266 let m = n.magnitude();
1267 if m <= tol.confusion() {
1268 ogeom_bail!(Construction, "the face has no normal at its midpoint");
1269 }
1270 let n = n / m;
1271 let scale = largest.sqrt().max(tol.confusion() * 1e3);
1272 for eps_scale in [1e-3, 1e-2, 5e-2] {
1273 let eps = scale * eps_scale;
1274 let deflection = ogeom_mesh::Deflection {
1275 chord: (eps * 0.1).max(1e-4),
1276 ..ogeom_mesh::Deflection::default()
1277 };
1278 let ahead = ogeom_algo::classify_in_solid(model, solid, p + n * eps, deflection, tol)?;
1279 let behind = ogeom_algo::classify_in_solid(model, solid, p - n * eps, deflection, tol)?;
1280 match (ahead, behind) {
1281 (Containment::Out, Containment::In) => return Ok(1.0),
1282 (Containment::In, Containment::Out) => return Ok(-1.0),
1283 _ => {}
1284 }
1285 }
1286 ogeom_bail!(
1287 Construction,
1288 "cannot read which side of the drafted face holds material; the wall is thinner than the probe can resolve"
1289 )
1290}
1291
1292fn meet(a: Plane, b: Plane, along: Vector, tol: Tolerances) -> OgeomResult<Point> {
1294 let rows = [a.normal().vector(), b.normal().vector(), along];
1295 let rhs = [
1296 rows[0].dot(a.origin().to_vector()),
1297 rows[1].dot(b.origin().to_vector()),
1298 along.dot(Point::midpoint(a.origin(), b.origin()).to_vector()),
1299 ];
1300 let det = rows[0].dot(rows[1].cross(rows[2]));
1301 if det.abs() <= tol.confusion() {
1302 ogeom_bail!(Construction, "the two planes do not meet in a line");
1303 }
1304 Ok(Point::ORIGIN
1305 + (rows[1].cross(rows[2]) * rhs[0]
1306 + rows[2].cross(rows[0]) * rhs[1]
1307 + rows[0].cross(rows[1]) * rhs[2])
1308 / det)
1309}