1use ogeom_algo::Built;
15use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
16use ogeom_geom::{Curve3d as _, 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 const ALONG: usize = 65;
396 let mut hinges: Vec<Point> = Vec::with_capacity(ALONG);
397 let mut rulings: Vec<Vector> = Vec::with_capacity(ALONG);
398 let (mut s_lo, mut s_hi) = (f64::INFINITY, f64::NEG_INFINITY);
399 for i in 0..ALONG {
400 #[allow(clippy::cast_precision_loss)]
401 let u = u0 + (u1 - u0) * (i as f64) / ((ALONG - 1) as f64);
402 let c = curve.point_at(u, tol)?;
403 let cd = curve.d1_at(u, tol)?;
404 let h = height_at(c);
405 let hinge = c + d * h;
406 let tangent = Direction::new(hinge_tangent(cd), tol)?;
407 let turn = Transform::rotation(ogeom_math::Axis::new(hinge, tangent), theta);
408 hinges.push(hinge);
409 rulings.push(turn.apply_vector(d));
410 s_lo = s_lo.min(v0 - h);
411 s_hi = s_hi.max(v1 - h);
412 }
413 let grow = (u1 - u0).abs().max((v1 - v0).abs()).mul_add(0.5, 1.0) * angle.abs().tan()
418 + tol.confusion();
419 let (s_lo, s_hi) = (s_lo - grow, s_hi + grow);
420 {
421 let extend = |hinges: &mut Vec<Point>, rulings: &mut Vec<Vector>, front: bool| {
424 let (i0, i1, i2) = if front {
425 (0, 1, 2)
426 } else {
427 let n = hinges.len();
428 (n - 1, n - 2, n - 3)
429 };
430 let d1 = hinges[i0] - hinges[i1];
431 let d2 = (hinges[i0] - hinges[i1]) - (hinges[i1] - hinges[i2]);
432 let r1 = rulings[i0] - rulings[i1];
433 let steps = (grow / d1.magnitude().max(tol.confusion())).ceil().max(2.0);
434 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
435 let steps = (steps as usize).min(16);
436 for k in 1..=steps {
437 #[allow(clippy::cast_precision_loss)]
438 let k = k as f64;
439 let station = (
440 hinges[i0] + d1 * k + d2 * (k * (k + 1.0) / 2.0),
441 rulings[i0] + r1 * k,
442 );
443 if front {
444 hinges.insert(0, station.0);
445 rulings.insert(0, station.1);
446 } else {
447 hinges.push(station.0);
448 rulings.push(station.1);
449 }
450 }
451 };
452 extend(&mut hinges, &mut rulings, true);
453 extend(&mut hinges, &mut rulings, false);
454 }
455 let along_total = hinges.len();
456
457 for edge in [s_lo, s_hi] {
460 for i in 0..along_total - 1 {
461 let step = (hinges[i + 1] + rulings[i + 1] * edge) - (hinges[i] + rulings[i] * edge);
462 if step.dot(hinges[i + 1] - hinges[i]) <= 0.0 {
463 ogeom_bail!(
464 Construction,
465 "the draft folds the wall onto itself inside the drafted \
466 window; refused; see docs/PARITY.md, offset.draft"
467 );
468 }
469 }
470 }
471
472 const ACROSS: usize = 9;
475 let rows: Vec<Vec<Point>> = (0..ACROSS)
476 .map(|j| {
477 #[allow(clippy::cast_precision_loss)]
478 let s = s_lo + (s_hi - s_lo) * (j as f64) / ((ACROSS - 1) as f64);
479 (0..along_total)
480 .map(|i| hinges[i] + rulings[i] * s)
481 .collect()
482 })
483 .collect();
484 let fit_target = (tol.confusion() * 1e3).max(1e-4);
485 let fitted = ogeom_geom::fit::fit_surface_grid(&rows, 3, fit_target, tol)?;
486 if !fitted.met {
487 ogeom_bail!(
488 NotDone,
489 "the drafted wall's fit reached {} against a target of {fit_target}",
490 fitted.error
491 );
492 }
493 Ok(fitted.curve.into())
494}
495
496const HINGE_STATIONS: usize = 256;
498
499#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
506fn general_draft(
507 model: &Model,
508 face: &Shape,
509 surface: &SurfaceGeometry,
510 sign: f64,
511 neutral: Plane,
512 pull: Direction,
513 angle: f64,
514 tol: Tolerances,
515) -> OgeomResult<SurfaceGeometry> {
516 let n = neutral.normal().vector();
517 let mesh = ogeom_mesh::triangulate_face(
518 model,
519 face,
520 ogeom_mesh::Deflection {
521 chord: 0.05,
522 angular: 0.2,
523 ..ogeom_mesh::Deflection::default()
524 },
525 tol,
526 )?;
527 let extent = {
528 let b = mesh
529 .positions
530 .iter()
531 .fold(ogeom_math::Aabb::EMPTY, |acc, p| acc.with_point(*p));
532 match (b.low(), b.high()) {
533 (Some(lo), Some(hi)) => (hi - lo).magnitude(),
534 _ => ogeom_bail!(Construction, "the drafted face has no extent"),
535 }
536 };
537 if extent <= tol.confusion() {
538 ogeom_bail!(Construction, "the drafted face has no extent");
539 }
540
541 let on = tol.confusion() * 10.0;
547 let side: Vec<f64> = mesh
548 .positions
549 .iter()
550 .map(|p| neutral.signed_distance_to(*p))
551 .collect();
552 let mut segments: Vec<[((f64, f64), Point); 2]> = Vec::new();
553 for t in &mesh.triangles {
554 let mut ends: Vec<((f64, f64), Point)> = Vec::with_capacity(2);
555 for &corner in t {
556 let i = corner as usize;
557 if side[i].abs() <= on {
558 ends.push((mesh.parameters[i], mesh.positions[i]));
559 }
560 }
561 for k in 0..3 {
562 let (i, j) = (t[k] as usize, t[(k + 1) % 3] as usize);
563 let (a, b) = (side[i], side[j]);
564 if a.abs() <= on || b.abs() <= on || (a < 0.0) == (b < 0.0) {
565 continue;
566 }
567 let f = a / (a - b);
568 let (pa, pb) = (mesh.parameters[i], mesh.parameters[j]);
569 let (qa, qb) = (mesh.positions[i], mesh.positions[j]);
570 ends.push((
571 (pa.0 + (pb.0 - pa.0) * f, pa.1 + (pb.1 - pa.1) * f),
572 qa + (qb - qa) * f,
573 ));
574 }
575 ends.dedup_by(|a, b| a.1.distance(b.1) <= on);
576 if ends.len() == 2 && ends[0].1.distance(ends[1].1) > on {
577 segments.push([ends[0], ends[1]]);
578 }
579 }
580 if segments.is_empty() {
581 ogeom_bail!(
582 Construction,
583 "the neutral plane does not cross the drafted face; there is no \
584 hinge to turn about"
585 );
586 }
587 let same = |a: Point, b: Point| a.distance(b) <= on;
593 let mut chain: Vec<((f64, f64), Point)> = vec![segments[0][0], segments[0][1]];
594 let mut used = vec![false; segments.len()];
595 used[0] = true;
596 loop {
597 let tail = chain[chain.len() - 1].1;
598 let head = chain[0].1;
599 let mut grew = false;
600 for (k, seg) in segments.iter().enumerate() {
601 if used[k] {
602 continue;
603 }
604 if (same(seg[0].1, tail) && same(seg[1].1, head))
607 || (same(seg[1].1, tail) && same(seg[0].1, head))
608 {
609 chain.push(chain[0]);
610 used[k] = true;
611 grew = true;
612 break;
613 }
614 let covered = |p: Point| chain.iter().any(|c| same(c.1, p));
615 if covered(seg[0].1) && covered(seg[1].1) {
616 used[k] = true;
617 continue;
618 }
619 if same(seg[0].1, tail) {
620 chain.push(seg[1]);
621 } else if same(seg[1].1, tail) {
622 chain.push(seg[0]);
623 } else if same(seg[0].1, head) {
624 chain.insert(0, seg[1]);
625 } else if same(seg[1].1, head) {
626 chain.insert(0, seg[0]);
627 } else {
628 continue;
629 }
630 used[k] = true;
631 grew = true;
632 }
633 if !grew {
634 break;
635 }
636 }
637 if used.iter().any(|u| !u) {
638 ogeom_bail!(
639 Construction,
640 "the neutral plane crosses the drafted face more than once; \
641 there is no one hinge to turn about"
642 );
643 }
644 let closed = chain.len() > 3 && same(chain[0].1, chain[chain.len() - 1].1);
645 if closed {
646 chain.pop();
647 }
648 if chain.len() < 2 {
649 ogeom_bail!(
650 Construction,
651 "the neutral plane touches the drafted face at a point; there is \
652 no hinge to turn about"
653 );
654 }
655 let chain: Vec<(f64, f64)> = chain.into_iter().map(|c| c.0).collect();
656
657 let chain: Vec<(f64, f64)> = {
663 let ((ua, ub), (va, vb)) = surface.domain();
664 let period = (
668 (surface.is_periodic_u() || surface.is_closed_u(tol)).then_some(ub - ua),
669 (surface.is_periodic_v() || surface.is_closed_v(tol)).then_some(vb - va),
670 );
671 let short = |a: f64, b: f64, period: Option<f64>| -> f64 {
672 let d = b - a;
673 match period {
674 Some(p) if d.abs() > p * 0.5 => d - p * d.signum(),
675 _ => d,
676 }
677 };
678 let chain: Vec<(f64, f64)> = if closed {
682 let n = chain.len();
686 let mut exact: Option<(usize, (f64, f64))> = None;
687 for i in 0..n {
688 let (a, b) = (chain[i], chain[(i + 1) % n]);
689 let (da, db) = (short(ua, a.0, period.0), short(ua, b.0, period.0));
690 if da == 0.0 {
691 exact = Some((i, a));
692 break;
693 }
694 if (da < 0.0) != (db < 0.0) && (da - db).abs() > 0.0 {
695 let f = da / (da - db);
696 let dv = short(a.1, b.1, period.1);
697 exact = Some((i + 1, (ua, a.1 + dv * f)));
698 break;
699 }
700 }
701 let (start, inserted) = exact.unwrap_or_else(|| {
702 let mut best = (0usize, f64::INFINITY);
703 for (i, c) in chain.iter().enumerate() {
704 let d = short(ua, c.0, period.0).abs();
705 if d < best.1 {
706 best = (i, d);
707 }
708 }
709 (best.0, chain[best.0])
710 });
711 let mut rotated: Vec<(f64, f64)> = Vec::with_capacity(n + 1);
712 rotated.push(inserted);
713 for k in 0..n {
714 let c = chain[(start + k) % n];
715 if rotated.len() == 1 && c == inserted {
716 continue;
717 }
718 rotated.push(c);
719 }
720 rotated
721 } else {
722 chain
723 };
724 let pairs = if closed { chain.len() } else { chain.len() - 1 };
728 let mut lengths = Vec::with_capacity(pairs);
729 let mut total = 0.0;
730 for i in 0..pairs {
731 let (a, b) = (chain[i], chain[(i + 1) % chain.len()]);
732 let step = surface
733 .point_at(a.0, a.1, tol)?
734 .distance(surface.point_at(b.0, b.1, tol)?);
735 lengths.push(step);
736 total += step;
737 }
738 let count = if closed {
739 HINGE_STATIONS
740 } else {
741 HINGE_STATIONS + 1
742 };
743 let mut dense = Vec::with_capacity(count);
744 let (mut pair, mut walked) = (0usize, 0.0_f64);
745 for k in 0..count {
746 #[allow(clippy::cast_precision_loss)]
747 let target = total * k as f64 / HINGE_STATIONS as f64;
748 while pair + 1 < pairs && walked + lengths[pair] < target {
749 walked += lengths[pair];
750 pair += 1;
751 }
752 let (a, b) = (chain[pair], chain[(pair + 1) % chain.len()]);
753 let (du, dv) = (short(a.0, b.0, period.0), short(a.1, b.1, period.1));
754 let f = if lengths[pair] > 0.0 {
755 ((target - walked) / lengths[pair]).clamp(0.0, 1.0)
756 } else {
757 0.0
758 };
759 dense.push((a.0 + du * f, a.1 + dv * f));
760 }
761 dense
762 };
763
764 let mut hinges: Vec<Point> = Vec::with_capacity(chain.len());
768 let mut tangents: Vec<Vector> = Vec::with_capacity(chain.len());
769 let mut outwards: Vec<Vector> = Vec::with_capacity(chain.len());
770 for (k, &(mut u, mut v)) in chain.iter().enumerate() {
771 let pinned = closed && k == 0;
774 for _ in 0..8 {
775 let p = surface.point_at(u, v, tol)?;
776 let f = neutral.signed_distance_to(p);
777 if f.abs() <= tol.confusion() * 1e-2 {
778 break;
779 }
780 let (du, dv) = surface.d1_at(u, v, tol)?;
781 let g = (if pinned { 0.0 } else { n.dot(du) }, n.dot(dv));
782 let g2 = g.0 * g.0 + g.1 * g.1;
783 if g2 <= 0.0 {
784 break;
785 }
786 u -= f * g.0 / g2;
787 v -= f * g.1 / g2;
788 }
789 let p = surface.point_at(u, v, tol)?;
790 let (du, dv) = surface.d1_at(u, v, tol)?;
791 let raw = du.cross(dv);
792 if raw.magnitude() <= tol.confusion() {
793 ogeom_bail!(Construction, "the drafted face has no normal on its hinge");
794 }
795 let outward = raw / raw.magnitude() * sign;
796 let mut t = outward.cross(n);
799 let next = chain[(k + 1) % chain.len()];
800 let prev = chain[(k + chain.len() - 1) % chain.len()];
801 let ahead =
802 surface.point_at(next.0, next.1, tol)? - surface.point_at(prev.0, prev.1, tol)?;
803 if t.dot(ahead) < 0.0 {
804 t = -t;
805 }
806 if t.magnitude() <= tol.angular() {
807 ogeom_bail!(
808 Construction,
809 "the neutral plane is tangent to the drafted face; there is no \
810 hinge to turn about"
811 );
812 }
813 hinges.push(p);
814 tangents.push(t / t.magnitude());
815 outwards.push(outward);
816 }
817
818 let m = hinges.len() / 2;
822 let axis_m = ogeom_math::Axis::new(hinges[m], Direction::new(tangents[m], tol)?);
823 let mut leaning = 1.0;
824 let mut best = f64::NEG_INFINITY;
825 for sense in [1.0_f64, -1.0] {
826 let turn = Transform::rotation(axis_m, angle.abs() * sense);
827 let lean = turn.apply_vector(outwards[m]).dot(pull.vector());
828 if lean > best {
829 best = lean;
830 leaning = sense;
831 }
832 }
833 let theta = angle * leaning;
834
835 let mut rulings: Vec<Vector> = Vec::with_capacity(hinges.len());
837 for (hinge, tangent) in hinges.iter().zip(&tangents) {
838 let turn = Transform::rotation(
839 ogeom_math::Axis::new(*hinge, Direction::new(*tangent, tol)?),
840 theta,
841 );
842 rulings.push(turn.apply_vector(pull.vector()));
843 }
844 let (mut s_lo, mut s_hi) = (f64::INFINITY, f64::NEG_INFINITY);
851 let (mut h_lo, mut h_hi) = (f64::INFINITY, f64::NEG_INFINITY);
852 for h in &hinges {
853 let s = h.to_vector().dot(pull.vector());
854 h_lo = h_lo.min(s);
855 h_hi = h_hi.max(s);
856 }
857 for p in &mesh.positions {
858 let s = p.to_vector().dot(pull.vector());
859 s_lo = s_lo.min(s - h_hi);
860 s_hi = s_hi.max(s - h_lo);
861 }
862 let grow = extent.mul_add(0.5, 1.0) * angle.abs().tan() + tol.confusion();
863 let (s_lo, s_hi) = (s_lo - grow, s_hi + grow);
864 if !closed {
865 let extend = |hinges: &mut Vec<Point>, rulings: &mut Vec<Vector>, front: bool| {
868 let (i0, i1) = if front {
869 (0, 1)
870 } else {
871 (hinges.len() - 1, hinges.len() - 2)
872 };
873 let d1 = hinges[i0] - hinges[i1];
874 let steps = (grow / d1.magnitude().max(tol.confusion())).ceil().max(2.0);
875 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
876 let steps = (steps as usize).min(16);
877 for k in 1..=steps {
878 #[allow(clippy::cast_precision_loss)]
879 let station = (hinges[i0] + d1 * k as f64, rulings[i0]);
880 if front {
881 hinges.insert(0, station.0);
882 rulings.insert(0, station.1);
883 } else {
884 hinges.push(station.0);
885 rulings.push(station.1);
886 }
887 }
888 };
889 extend(&mut hinges, &mut rulings, true);
890 extend(&mut hinges, &mut rulings, false);
891 } else {
892 hinges.push(hinges[0]);
893 rulings.push(rulings[0]);
894 }
895 for edge in [s_lo, s_hi] {
896 for i in 0..hinges.len() - 1 {
897 let step = (hinges[i + 1] + rulings[i + 1] * edge) - (hinges[i] + rulings[i] * edge);
898 if step.dot(hinges[i + 1] - hinges[i]) <= 0.0 {
899 ogeom_bail!(
900 Construction,
901 "the draft folds the wall onto itself inside the drafted \
902 window; refused; see docs/PARITY.md, offset.draft"
903 );
904 }
905 }
906 }
907 let params: Vec<f64> = {
921 let mut out = Vec::with_capacity(hinges.len());
922 let mut total = 0.0;
923 out.push(0.0);
924 for pair in hinges.windows(2) {
925 total += pair[0].distance(pair[1]);
926 out.push(total);
927 }
928 if total > 0.0 {
929 for t in &mut out {
930 *t /= total;
931 }
932 }
933 if let Some(last) = out.last_mut() {
934 *last = 1.0;
935 }
936 out
937 };
938 let reach = s_lo.abs().max(s_hi.abs()).max(1.0);
939 let fit_target = (tol.confusion() * 1e3).max(1e-4);
940 let hinge_fit = ogeom_geom::fit::fit_points_at(¶ms, &hinges, 3, fit_target, tol)?;
941 let tips: Vec<Point> = hinges.iter().zip(&rulings).map(|(h, r)| *h + *r).collect();
942 let tip_fit = ogeom_geom::fit::fit_points_at(¶ms, &tips, 3, fit_target / reach, tol)?;
943 if !hinge_fit.met || !tip_fit.met {
944 ogeom_bail!(
945 NotDone,
946 "the drafted wall's hinge fit reached {} and its rulings' {} against \
947 a target of {fit_target}",
948 hinge_fit.error,
949 tip_fit.error * reach
950 );
951 }
952 let (mut hinge_curve, mut tip_curve) = (hinge_fit.curve, tip_fit.curve);
953 for (value, count) in tip_curve.knots().distinct() {
954 let have = hinge_curve.knots().multiplicity_of(value);
955 if count > have {
956 hinge_curve = hinge_curve.with_knot_inserted(value, count - have, tol)?;
957 }
958 }
959 for (value, count) in hinge_curve.knots().distinct() {
960 let have = tip_curve.knots().multiplicity_of(value);
961 if count > have {
962 tip_curve = tip_curve.with_knot_inserted(value, count - have, tol)?;
963 }
964 }
965 let (hc, tc) = (hinge_curve.control_points(), tip_curve.control_points());
966 if hc.len() != tc.len() {
967 ogeom_bail!(
968 Construction,
969 "the hinge and its rulings did not share a knot vector"
970 );
971 }
972 let (hinge_mid, tip_mid) = (
977 hinge_curve.point_at(0.5, tol)?,
978 tip_curve.point_at(0.5, tol)?,
979 );
980 let across = hinge_curve.d1_at(0.5, tol)?;
981 let old_normal = {
982 let foot = ogeom_algo::project_on_surface(surface, hinge_mid, 16, tol)?;
983 let (u, v) = foot.parameters;
984 surface.normal_at(u, v, tol)?.vector()
985 };
986 let agrees = across.cross(tip_mid - hinge_mid).dot(old_normal) >= 0.0;
989 let (first, second, v_range) = if agrees {
990 (s_lo, s_hi, (s_lo, s_hi))
991 } else {
992 (s_hi, s_lo, (-s_hi, -s_lo))
993 };
994 let mut net: Vec<Point> = Vec::with_capacity(hc.len() * 2);
995 for (h, t) in hc.iter().zip(tc) {
996 let (h, d) = (h.point(), t.point() - h.point());
997 net.push(h + d * first);
998 net.push(h + d * second);
999 }
1000 let grid = ogeom_math::ControlGrid::new(net, hc.len(), 2)?;
1001 let u_knots = if closed {
1005 let ((ua, ub), _) = surface.domain();
1006 hinge_curve.knots().reparameterized(ua, ub)?
1007 } else {
1008 hinge_curve.knots().clone()
1009 };
1010 let v_knots =
1011 ogeom_math::KnotVector::clamped_uniform(1, 2)?.reparameterized(v_range.0, v_range.1)?;
1012 Ok(ogeom_geom::BSplineSurface::new(u_knots, v_knots, &grid, tol)?.into())
1013}
1014
1015fn outward_sign(
1024 model: &Model,
1025 solid: &Shape,
1026 face: &Shape,
1027 surface: &SurfaceGeometry,
1028 tol: Tolerances,
1029) -> OgeomResult<f64> {
1030 use ogeom_algo::Containment;
1031 let mesh = ogeom_mesh::triangulate_face(model, face, ogeom_mesh::Deflection::default(), tol)?;
1035 let mut at = None;
1036 let mut largest = 0.0_f64;
1037 for t in &mesh.triangles {
1038 let [a, b, c] = [
1039 mesh.positions[t[0] as usize],
1040 mesh.positions[t[1] as usize],
1041 mesh.positions[t[2] as usize],
1042 ];
1043 let area = (b - a).cross(c - a).magnitude();
1044 if area > largest {
1045 largest = area;
1046 let params = [
1047 mesh.parameters[t[0] as usize],
1048 mesh.parameters[t[1] as usize],
1049 mesh.parameters[t[2] as usize],
1050 ];
1051 at = Some((
1052 (params[0].0 + params[1].0 + params[2].0) / 3.0,
1053 (params[0].1 + params[1].1 + params[2].1) / 3.0,
1054 ));
1055 }
1056 }
1057 let Some((um, vm)) = at else {
1058 ogeom_bail!(Construction, "the drafted face has no interior to probe");
1059 };
1060 let p = surface.point_at(um, vm, tol)?;
1061 let (du, dv) = surface.d1_at(um, vm, tol)?;
1062 let n = du.cross(dv);
1063 let m = n.magnitude();
1064 if m <= tol.confusion() {
1065 ogeom_bail!(Construction, "the face has no normal at its midpoint");
1066 }
1067 let n = n / m;
1068 let scale = largest.sqrt().max(tol.confusion() * 1e3);
1069 for eps_scale in [1e-3, 1e-2, 5e-2] {
1070 let eps = scale * eps_scale;
1071 let deflection = ogeom_mesh::Deflection {
1072 chord: (eps * 0.1).max(1e-4),
1073 ..ogeom_mesh::Deflection::default()
1074 };
1075 let ahead = ogeom_algo::classify_in_solid(model, solid, p + n * eps, deflection, tol)?;
1076 let behind = ogeom_algo::classify_in_solid(model, solid, p - n * eps, deflection, tol)?;
1077 match (ahead, behind) {
1078 (Containment::Out, Containment::In) => return Ok(1.0),
1079 (Containment::In, Containment::Out) => return Ok(-1.0),
1080 _ => {}
1081 }
1082 }
1083 ogeom_bail!(
1084 Construction,
1085 "cannot read which side of the drafted face holds material; the wall is thinner than the probe can resolve"
1086 )
1087}
1088
1089fn meet(a: Plane, b: Plane, along: Vector, tol: Tolerances) -> OgeomResult<Point> {
1091 let rows = [a.normal().vector(), b.normal().vector(), along];
1092 let rhs = [
1093 rows[0].dot(a.origin().to_vector()),
1094 rows[1].dot(b.origin().to_vector()),
1095 along.dot(Point::midpoint(a.origin(), b.origin()).to_vector()),
1096 ];
1097 let det = rows[0].dot(rows[1].cross(rows[2]));
1098 if det.abs() <= tol.confusion() {
1099 ogeom_bail!(Construction, "the two planes do not meet in a line");
1100 }
1101 Ok(Point::ORIGIN
1102 + (rows[1].cross(rows[2]) * rhs[0]
1103 + rows[2].cross(rows[0]) * rhs[1]
1104 + rows[0].cross(rows[1]) * rhs[2])
1105 / det)
1106}