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(
40 model: &mut Model,
41 solid: &Shape,
42 faces: &[Shape],
43 neutral: Plane,
44 pull: Direction,
45 angle: f64,
46 tol: Tolerances,
47) -> OgeomResult<Built> {
48 if !angle.is_finite() || angle.abs() >= core::f64::consts::FRAC_PI_2 {
49 ogeom_bail!(
50 Construction,
51 "a draft of {angle} radians turns the face past its own plane"
52 );
53 }
54 let (canonical, mapped, prefix) = crate::shape::canonical_input(model, solid, faces, tol)?;
55 if let Some(prefix) = prefix {
56 let mut out = apply_draft(model, &canonical, &mapped, neutral, pull, angle, tol)?;
57 out.history = prefix.then(&out.history);
58 return Ok(out);
59 }
60 if faces.is_empty() {
61 ogeom_bail!(Construction, "a draft of no faces drafts nothing");
62 }
63 let own: Vec<Shape> = {
67 let mut seen: Vec<Shape> = Vec::new();
68 for f in ogeom_topo::explore(model, solid, ogeom_topo::Filter::OfType(ShapeType::Face))? {
69 if !seen.iter().any(|s| s.node() == f.node()) {
70 seen.push(f);
71 }
72 }
73 seen
74 };
75
76 let mut turned: Vec<(TShapeId, SurfaceGeometry)> = Vec::with_capacity(faces.len());
80 for face in faces {
81 let Some(used) = own.iter().find(|f| f.node() == face.node()).cloned() else {
82 ogeom_bail!(Construction, "a drafted face is not a face of the solid");
83 };
84 let face = &used;
85 let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data().clone()) else {
86 ogeom_bail!(Construction, "expected a face");
87 };
88 let Some(surface) = model.geometry().surface(data.surface) else {
89 ogeom_bail!(Dangling, "face refers to a surface not in this model");
90 };
91 let sign = outward_sign(model, solid, face, surface, tol)?;
96 let sign_of = |_: &Shape| sign;
97 let axial = |frame: Frame| -> bool {
100 (frame.z().vector().dot(neutral.normal().vector()).abs() - 1.0).abs()
101 <= tol.angular().max(1e-9)
102 };
103 match surface {
104 SurfaceGeometry::Cylinder(c) if axial(c.cylinder().frame()) => {
105 let cylinder = c.cylinder();
106 let (_, (v0, v1)) = surface.domain();
107 turned.push((
108 face.node(),
109 revolved_draft(
110 cylinder.frame(),
111 cylinder.radius(),
112 0.0,
113 (v0, v1),
114 sign_of(face),
115 neutral,
116 pull,
117 angle,
118 tol,
119 )?,
120 ));
121 continue;
122 }
123 SurfaceGeometry::Cone(co) if axial(co.cone().frame()) => {
124 let cone = co.cone();
125 let (_, (v0, v1)) = surface.domain();
126 turned.push((
127 face.node(),
128 revolved_draft(
129 cone.frame(),
130 cone.reference_radius(),
131 cone.half_angle(),
132 (v0, v1),
133 sign_of(face),
134 neutral,
135 pull,
136 angle,
137 tol,
138 )?,
139 ));
140 continue;
141 }
142 SurfaceGeometry::Extrusion(e) => {
143 turned.push((
144 face.node(),
145 extruded_draft(
146 e,
147 surface.domain(),
148 sign_of(face),
149 neutral,
150 pull,
151 angle,
152 tol,
153 )?,
154 ));
155 continue;
156 }
157 SurfaceGeometry::Plane(_) => {}
158 _ => {
163 turned.push((
164 face.node(),
165 general_draft(model, face, surface, sign, neutral, pull, angle, tol)?,
166 ));
167 continue;
168 }
169 }
170 let SurfaceGeometry::Plane(p) = surface else {
171 unreachable!("the match above let only planes through");
172 };
173 let plane = p.plane();
174 let ((u0, u1), (v0, v1)) = surface.domain();
175 let outward = plane.normal().vector() * sign;
178
179 let along = plane.normal().vector().cross(neutral.normal().vector());
181 let magnitude = along.magnitude();
182 if magnitude <= tol.angular() {
183 ogeom_bail!(
184 Construction,
185 "a face parallel to the neutral plane has no line to turn \
186 about"
187 );
188 }
189 let along = along / magnitude;
190 let hinge = meet(plane, neutral, along, tol)?;
191
192 let axis = ogeom_math::Axis::new(hinge, Direction::new(along, tol)?);
197 let mut candidates = Vec::with_capacity(2);
198 for sense in [1.0, -1.0] {
199 let turn = Transform::rotation(axis, angle.abs() * sense);
200 candidates.push((sense, turn.apply_vector(outward).dot(pull.vector())));
201 }
202 let leaning = candidates
203 .iter()
204 .copied()
205 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal))
206 .map_or(1.0, |(sense, _)| sense);
207 let turn = Transform::rotation(axis, angle * leaning);
208 let moved_normal = Direction::new(turn.apply_vector(plane.normal().vector()), tol)?;
209 let tilted = Plane::new(Frame::new(
210 hinge,
211 moved_normal,
212 Direction::new(along, tol)?,
213 tol,
214 )?);
215 let grow = (u1 - u0).abs().max((v1 - v0).abs()).mul_add(0.5, 1.0) * angle.abs().tan()
218 + tol.confusion();
219 turned.push((
220 face.node(),
221 PlaneSurface::over(tilted, (u0 - grow, u1 + grow), (v0 - grow, v1 + grow))?.into(),
222 ));
223 }
224
225 rebuilt(
226 model,
227 solid,
228 &|_| 0.0,
229 &|face| {
230 turned
231 .iter()
232 .find(|(node, _)| *node == face.node())
233 .map(|(_, surface)| surface.clone())
234 },
235 tol,
236 )
237}
238
239#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
243fn revolved_draft(
244 frame: Frame,
245 reference_radius: f64,
246 half_angle: f64,
247 window: (f64, f64),
248 sign: f64,
249 neutral: Plane,
250 pull: Direction,
251 angle: f64,
252 tol: Tolerances,
253) -> OgeomResult<SurfaceGeometry> {
254 use ogeom_geom::ConeSurface;
255
256 let axis_dir = frame.z().vector();
257 let along = axis_dir.dot(neutral.normal().vector());
258 if (along.abs() - 1.0).abs() > tol.angular().max(1e-9) {
259 ogeom_bail!(
260 Construction,
261 "a wall of revolution drafts about a neutral plane square to \
262 its axis; the oblique neutral needs the general machinery (see \
263 docs/PARITY.md, offset.draft)"
264 );
265 }
266 let height = -neutral.signed_distance_to(frame.origin()) * along.signum();
269 let neutral_point = frame.origin() + axis_dir * height;
270 let neutral_radius = half_angle.tan().mul_add(height, reference_radius);
271 if neutral_radius <= tol.confusion() {
272 ogeom_bail!(
273 Construction,
274 "the wall has no radius left at the neutral plane to hold"
275 );
276 }
277 let hinge_frame = Frame::new(neutral_point, frame.z(), frame.x(), tol)?;
278
279 let mut best: Option<(f64, f64)> = None;
283 for sense in [1.0_f64, -1.0] {
284 let probe = half_angle + angle.abs() * sense;
287 let candidate = half_angle + angle * sense;
288 if probe.abs() <= tol.angular()
289 || probe.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular()
290 || candidate.abs() <= tol.angular()
291 || candidate.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular()
292 {
293 continue;
294 }
295 let cone = ogeom_math::Cone::new(hinge_frame, neutral_radius, probe, tol)?;
296 let surface: SurfaceGeometry = ConeSurface::new(cone, (-1.0, 1.0))?.into();
297 let (du, dv) = surface.d1_at(0.0, 1.0, tol)?;
298 let n = du.cross(dv);
299 let outward = n / n.magnitude() * sign;
300 let lean = outward.dot(pull.vector());
301 if best.as_ref().is_none_or(|(_, held)| lean > *held) {
302 best = Some((candidate, lean));
303 }
304 }
305 let Some((leaned, _)) = best else {
306 ogeom_bail!(
307 Construction,
308 "a draft of {angle} radians flattens the wall or swallows it"
309 );
310 };
311 let cone = ogeom_math::Cone::new(hinge_frame, neutral_radius, leaned, tol)?;
312
313 let shift = height;
316 let grow = (window.1 - window.0).abs().mul_add(0.1, 1.0);
317 let (w0, w1) = (window.0 - shift - grow, window.1 - shift + grow);
318 let apex_height = -neutral_radius / leaned.tan();
319 if apex_height > w0 && apex_height < w1 {
320 ogeom_bail!(
321 Construction,
322 "the draft swallows the drafted face's own apex"
323 );
324 }
325 Ok(ConeSurface::new(cone, (w0, w1))?.into())
326}
327
328#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
340fn extruded_draft(
341 extrusion: &ogeom_geom::ExtrusionSurface,
342 window: ((f64, f64), (f64, f64)),
343 sign: f64,
344 neutral: Plane,
345 pull: Direction,
346 angle: f64,
347 tol: Tolerances,
348) -> OgeomResult<SurfaceGeometry> {
349 use ogeom_geom::Curve3d as _;
350 let ((u0, u1), (v0, v1)) = window;
351 let d = extrusion.direction().vector();
352 let n = neutral.normal().vector();
353 let den = n.dot(d);
354 if den.abs() <= tol.angular() {
355 ogeom_bail!(
356 Construction,
357 "the neutral plane runs along the wall's rulings; there is no \
358 hinge to turn about"
359 );
360 }
361 let curve = extrusion.curve();
362 let o = neutral.origin().to_vector();
363 let height_at = |c: Point| n.dot(o - c.to_vector()) / den;
366 let hinge_tangent = |cd: Vector| cd - d * (n.dot(cd) / den);
367
368 let um = f64::midpoint(u0, u1);
373 let cm = curve.point_at(um, tol)?;
374 let cdm = curve.d1_at(um, tol)?;
375 let hinge_m = cm + d * height_at(cm);
376 let tangent_m = Direction::new(hinge_tangent(cdm), tol)?;
377 let outward = {
378 let nw = cdm.cross(d);
379 nw / nw.magnitude() * sign
380 };
381 let axis_m = ogeom_math::Axis::new(hinge_m, tangent_m);
382 let mut leaning = 1.0;
383 let mut best = f64::NEG_INFINITY;
384 for sense in [1.0_f64, -1.0] {
385 let turn = Transform::rotation(axis_m, angle.abs() * sense);
386 let lean = turn.apply_vector(outward).dot(pull.vector());
387 if lean > best {
388 best = lean;
389 leaning = sense;
390 }
391 }
392 let theta = angle * leaning;
393
394 const ALONG: usize = 65;
397 let mut hinges: Vec<Point> = Vec::with_capacity(ALONG);
398 let mut rulings: Vec<Vector> = Vec::with_capacity(ALONG);
399 let (mut s_lo, mut s_hi) = (f64::INFINITY, f64::NEG_INFINITY);
400 for i in 0..ALONG {
401 #[allow(clippy::cast_precision_loss)]
402 let u = u0 + (u1 - u0) * (i as f64) / ((ALONG - 1) as f64);
403 let c = curve.point_at(u, tol)?;
404 let cd = curve.d1_at(u, tol)?;
405 let h = height_at(c);
406 let hinge = c + d * h;
407 let tangent = Direction::new(hinge_tangent(cd), tol)?;
408 let turn = Transform::rotation(ogeom_math::Axis::new(hinge, tangent), theta);
409 hinges.push(hinge);
410 rulings.push(turn.apply_vector(d));
411 s_lo = s_lo.min(v0 - h);
412 s_hi = s_hi.max(v1 - h);
413 }
414 let grow = (u1 - u0).abs().max((v1 - v0).abs()).mul_add(0.5, 1.0) * angle.abs().tan()
419 + tol.confusion();
420 let (s_lo, s_hi) = (s_lo - grow, s_hi + grow);
421 {
422 let extend = |hinges: &mut Vec<Point>, rulings: &mut Vec<Vector>, front: bool| {
425 let (i0, i1, i2) = if front {
426 (0, 1, 2)
427 } else {
428 let n = hinges.len();
429 (n - 1, n - 2, n - 3)
430 };
431 let d1 = hinges[i0] - hinges[i1];
432 let d2 = (hinges[i0] - hinges[i1]) - (hinges[i1] - hinges[i2]);
433 let r1 = rulings[i0] - rulings[i1];
434 let steps = (grow / d1.magnitude().max(tol.confusion())).ceil().max(2.0);
435 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
436 let steps = (steps as usize).min(16);
437 for k in 1..=steps {
438 #[allow(clippy::cast_precision_loss)]
439 let k = k as f64;
440 let station = (
441 hinges[i0] + d1 * k + d2 * (k * (k + 1.0) / 2.0),
442 rulings[i0] + r1 * k,
443 );
444 if front {
445 hinges.insert(0, station.0);
446 rulings.insert(0, station.1);
447 } else {
448 hinges.push(station.0);
449 rulings.push(station.1);
450 }
451 }
452 };
453 extend(&mut hinges, &mut rulings, true);
454 extend(&mut hinges, &mut rulings, false);
455 }
456 let along_total = hinges.len();
457
458 for edge in [s_lo, s_hi] {
461 for i in 0..along_total - 1 {
462 let step = (hinges[i + 1] + rulings[i + 1] * edge) - (hinges[i] + rulings[i] * edge);
463 if step.dot(hinges[i + 1] - hinges[i]) <= 0.0 {
464 ogeom_bail!(
465 Construction,
466 "the draft folds the wall onto itself inside the drafted \
467 window; refused; see docs/PARITY.md, offset.draft"
468 );
469 }
470 }
471 }
472
473 const ACROSS: usize = 9;
476 let rows: Vec<Vec<Point>> = (0..ACROSS)
477 .map(|j| {
478 #[allow(clippy::cast_precision_loss)]
479 let s = s_lo + (s_hi - s_lo) * (j as f64) / ((ACROSS - 1) as f64);
480 (0..along_total)
481 .map(|i| hinges[i] + rulings[i] * s)
482 .collect()
483 })
484 .collect();
485 let fit_target = (tol.confusion() * 1e3).max(1e-4);
486 let fitted = ogeom_geom::fit::fit_surface_grid(&rows, 3, fit_target, tol)?;
487 if !fitted.met {
488 ogeom_bail!(
489 NotDone,
490 "the drafted wall's fit reached {} against a target of {fit_target}",
491 fitted.error
492 );
493 }
494 Ok(fitted.curve.into())
495}
496
497const HINGE_STATIONS: usize = 256;
499
500#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
507fn general_draft(
508 model: &Model,
509 face: &Shape,
510 surface: &SurfaceGeometry,
511 sign: f64,
512 neutral: Plane,
513 pull: Direction,
514 angle: f64,
515 tol: Tolerances,
516) -> OgeomResult<SurfaceGeometry> {
517 let n = neutral.normal().vector();
518 let mesh = ogeom_mesh::triangulate_face(
519 model,
520 face,
521 ogeom_mesh::Deflection {
522 chord: 0.05,
523 angular: 0.2,
524 ..ogeom_mesh::Deflection::default()
525 },
526 tol,
527 )?;
528 let extent = {
529 let b = mesh
530 .positions
531 .iter()
532 .fold(ogeom_math::Aabb::EMPTY, |acc, p| acc.with_point(*p));
533 match (b.low(), b.high()) {
534 (Some(lo), Some(hi)) => (hi - lo).magnitude(),
535 _ => ogeom_bail!(Construction, "the drafted face has no extent"),
536 }
537 };
538 if extent <= tol.confusion() {
539 ogeom_bail!(Construction, "the drafted face has no extent");
540 }
541
542 let on = tol.confusion() * 10.0;
548 let side: Vec<f64> = mesh
549 .positions
550 .iter()
551 .map(|p| neutral.signed_distance_to(*p))
552 .collect();
553 let mut segments: Vec<[((f64, f64), Point); 2]> = Vec::new();
554 for t in &mesh.triangles {
555 let mut ends: Vec<((f64, f64), Point)> = Vec::with_capacity(2);
556 for &corner in t {
557 let i = corner as usize;
558 if side[i].abs() <= on {
559 ends.push((mesh.parameters[i], mesh.positions[i]));
560 }
561 }
562 for k in 0..3 {
563 let (i, j) = (t[k] as usize, t[(k + 1) % 3] as usize);
564 let (a, b) = (side[i], side[j]);
565 if a.abs() <= on || b.abs() <= on || (a < 0.0) == (b < 0.0) {
566 continue;
567 }
568 let f = a / (a - b);
569 let (pa, pb) = (mesh.parameters[i], mesh.parameters[j]);
570 let (qa, qb) = (mesh.positions[i], mesh.positions[j]);
571 ends.push((
572 (pa.0 + (pb.0 - pa.0) * f, pa.1 + (pb.1 - pa.1) * f),
573 qa + (qb - qa) * f,
574 ));
575 }
576 ends.dedup_by(|a, b| a.1.distance(b.1) <= on);
577 if ends.len() == 2 && ends[0].1.distance(ends[1].1) > on {
578 segments.push([ends[0], ends[1]]);
579 }
580 }
581 if segments.is_empty() {
582 ogeom_bail!(
583 Construction,
584 "the neutral plane does not cross the drafted face; there is no \
585 hinge to turn about"
586 );
587 }
588 let same = |a: Point, b: Point| a.distance(b) <= on;
594 let mut chain: Vec<((f64, f64), Point)> = vec![segments[0][0], segments[0][1]];
595 let mut used = vec![false; segments.len()];
596 used[0] = true;
597 loop {
598 let tail = chain[chain.len() - 1].1;
599 let head = chain[0].1;
600 let mut grew = false;
601 for (k, seg) in segments.iter().enumerate() {
602 if used[k] {
603 continue;
604 }
605 if (same(seg[0].1, tail) && same(seg[1].1, head))
608 || (same(seg[1].1, tail) && same(seg[0].1, head))
609 {
610 chain.push(chain[0]);
611 used[k] = true;
612 grew = true;
613 break;
614 }
615 let covered = |p: Point| chain.iter().any(|c| same(c.1, p));
616 if covered(seg[0].1) && covered(seg[1].1) {
617 used[k] = true;
618 continue;
619 }
620 if same(seg[0].1, tail) {
621 chain.push(seg[1]);
622 } else if same(seg[1].1, tail) {
623 chain.push(seg[0]);
624 } else if same(seg[0].1, head) {
625 chain.insert(0, seg[1]);
626 } else if same(seg[1].1, head) {
627 chain.insert(0, seg[0]);
628 } else {
629 continue;
630 }
631 used[k] = true;
632 grew = true;
633 }
634 if !grew {
635 break;
636 }
637 }
638 if used.iter().any(|u| !u) {
639 ogeom_bail!(
640 Construction,
641 "the neutral plane crosses the drafted face more than once; \
642 there is no one hinge to turn about"
643 );
644 }
645 let closed = chain.len() > 3 && same(chain[0].1, chain[chain.len() - 1].1);
646 if closed {
647 chain.pop();
648 }
649 if chain.len() < 2 {
650 ogeom_bail!(
651 Construction,
652 "the neutral plane touches the drafted face at a point; there is \
653 no hinge to turn about"
654 );
655 }
656 let chain: Vec<(f64, f64)> = chain.into_iter().map(|c| c.0).collect();
657
658 let chain: Vec<(f64, f64)> = {
664 let ((ua, ub), (va, vb)) = surface.domain();
665 let period = (
669 (surface.is_periodic_u() || surface.is_closed_u(tol)).then_some(ub - ua),
670 (surface.is_periodic_v() || surface.is_closed_v(tol)).then_some(vb - va),
671 );
672 let short = |a: f64, b: f64, period: Option<f64>| -> f64 {
673 let d = b - a;
674 match period {
675 Some(p) if d.abs() > p * 0.5 => d - p * d.signum(),
676 _ => d,
677 }
678 };
679 let chain: Vec<(f64, f64)> = if closed {
683 let n = chain.len();
687 let mut exact: Option<(usize, (f64, f64))> = None;
688 for i in 0..n {
689 let (a, b) = (chain[i], chain[(i + 1) % n]);
690 let (da, db) = (short(ua, a.0, period.0), short(ua, b.0, period.0));
691 if da == 0.0 {
692 exact = Some((i, a));
693 break;
694 }
695 if (da < 0.0) != (db < 0.0) && (da - db).abs() > 0.0 {
696 let f = da / (da - db);
697 let dv = short(a.1, b.1, period.1);
698 exact = Some((i + 1, (ua, a.1 + dv * f)));
699 break;
700 }
701 }
702 let (start, inserted) = exact.unwrap_or_else(|| {
703 let mut best = (0usize, f64::INFINITY);
704 for (i, c) in chain.iter().enumerate() {
705 let d = short(ua, c.0, period.0).abs();
706 if d < best.1 {
707 best = (i, d);
708 }
709 }
710 (best.0, chain[best.0])
711 });
712 let mut rotated: Vec<(f64, f64)> = Vec::with_capacity(n + 1);
713 rotated.push(inserted);
714 for k in 0..n {
715 let c = chain[(start + k) % n];
716 if rotated.len() == 1 && c == inserted {
717 continue;
718 }
719 rotated.push(c);
720 }
721 rotated
722 } else {
723 chain
724 };
725 let pairs = if closed { chain.len() } else { chain.len() - 1 };
729 let mut lengths = Vec::with_capacity(pairs);
730 let mut total = 0.0;
731 for i in 0..pairs {
732 let (a, b) = (chain[i], chain[(i + 1) % chain.len()]);
733 let step = surface
734 .point_at(a.0, a.1, tol)?
735 .distance(surface.point_at(b.0, b.1, tol)?);
736 lengths.push(step);
737 total += step;
738 }
739 let count = if closed {
740 HINGE_STATIONS
741 } else {
742 HINGE_STATIONS + 1
743 };
744 let mut dense = Vec::with_capacity(count);
745 let (mut pair, mut walked) = (0usize, 0.0_f64);
746 for k in 0..count {
747 #[allow(clippy::cast_precision_loss)]
748 let target = total * k as f64 / HINGE_STATIONS as f64;
749 while pair + 1 < pairs && walked + lengths[pair] < target {
750 walked += lengths[pair];
751 pair += 1;
752 }
753 let (a, b) = (chain[pair], chain[(pair + 1) % chain.len()]);
754 let (du, dv) = (short(a.0, b.0, period.0), short(a.1, b.1, period.1));
755 let f = if lengths[pair] > 0.0 {
756 ((target - walked) / lengths[pair]).clamp(0.0, 1.0)
757 } else {
758 0.0
759 };
760 dense.push((a.0 + du * f, a.1 + dv * f));
761 }
762 dense
763 };
764
765 let mut hinges: Vec<Point> = Vec::with_capacity(chain.len());
769 let mut tangents: Vec<Vector> = Vec::with_capacity(chain.len());
770 let mut outwards: Vec<Vector> = Vec::with_capacity(chain.len());
771 for (k, &(mut u, mut v)) in chain.iter().enumerate() {
772 let pinned = closed && k == 0;
775 for _ in 0..8 {
776 let p = surface.point_at(u, v, tol)?;
777 let f = neutral.signed_distance_to(p);
778 if f.abs() <= tol.confusion() * 1e-2 {
779 break;
780 }
781 let (du, dv) = surface.d1_at(u, v, tol)?;
782 let g = (if pinned { 0.0 } else { n.dot(du) }, n.dot(dv));
783 let g2 = g.0 * g.0 + g.1 * g.1;
784 if g2 <= 0.0 {
785 break;
786 }
787 u -= f * g.0 / g2;
788 v -= f * g.1 / g2;
789 }
790 let p = surface.point_at(u, v, tol)?;
791 let (du, dv) = surface.d1_at(u, v, tol)?;
792 let raw = du.cross(dv);
793 if raw.magnitude() <= tol.confusion() {
794 ogeom_bail!(Construction, "the drafted face has no normal on its hinge");
795 }
796 let outward = raw / raw.magnitude() * sign;
797 let mut t = outward.cross(n);
800 let next = chain[(k + 1) % chain.len()];
801 let prev = chain[(k + chain.len() - 1) % chain.len()];
802 let ahead =
803 surface.point_at(next.0, next.1, tol)? - surface.point_at(prev.0, prev.1, tol)?;
804 if t.dot(ahead) < 0.0 {
805 t = -t;
806 }
807 if t.magnitude() <= tol.angular() {
808 ogeom_bail!(
809 Construction,
810 "the neutral plane is tangent to the drafted face; there is no \
811 hinge to turn about"
812 );
813 }
814 hinges.push(p);
815 tangents.push(t / t.magnitude());
816 outwards.push(outward);
817 }
818
819 let m = hinges.len() / 2;
823 let axis_m = ogeom_math::Axis::new(hinges[m], Direction::new(tangents[m], tol)?);
824 let mut leaning = 1.0;
825 let mut best = f64::NEG_INFINITY;
826 for sense in [1.0_f64, -1.0] {
827 let turn = Transform::rotation(axis_m, angle.abs() * sense);
828 let lean = turn.apply_vector(outwards[m]).dot(pull.vector());
829 if lean > best {
830 best = lean;
831 leaning = sense;
832 }
833 }
834 let theta = angle * leaning;
835
836 let mut rulings: Vec<Vector> = Vec::with_capacity(hinges.len());
838 for (hinge, tangent) in hinges.iter().zip(&tangents) {
839 let turn = Transform::rotation(
840 ogeom_math::Axis::new(*hinge, Direction::new(*tangent, tol)?),
841 theta,
842 );
843 rulings.push(turn.apply_vector(pull.vector()));
844 }
845 let (mut s_lo, mut s_hi) = (f64::INFINITY, f64::NEG_INFINITY);
852 let (mut h_lo, mut h_hi) = (f64::INFINITY, f64::NEG_INFINITY);
853 for h in &hinges {
854 let s = h.to_vector().dot(pull.vector());
855 h_lo = h_lo.min(s);
856 h_hi = h_hi.max(s);
857 }
858 for p in &mesh.positions {
859 let s = p.to_vector().dot(pull.vector());
860 s_lo = s_lo.min(s - h_hi);
861 s_hi = s_hi.max(s - h_lo);
862 }
863 let grow = extent.mul_add(0.5, 1.0) * angle.abs().tan() + tol.confusion();
864 let (s_lo, s_hi) = (s_lo - grow, s_hi + grow);
865 if !closed {
866 let extend = |hinges: &mut Vec<Point>, rulings: &mut Vec<Vector>, front: bool| {
869 let (i0, i1) = if front {
870 (0, 1)
871 } else {
872 (hinges.len() - 1, hinges.len() - 2)
873 };
874 let d1 = hinges[i0] - hinges[i1];
875 let steps = (grow / d1.magnitude().max(tol.confusion())).ceil().max(2.0);
876 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
877 let steps = (steps as usize).min(16);
878 for k in 1..=steps {
879 #[allow(clippy::cast_precision_loss)]
880 let station = (hinges[i0] + d1 * k as f64, rulings[i0]);
881 if front {
882 hinges.insert(0, station.0);
883 rulings.insert(0, station.1);
884 } else {
885 hinges.push(station.0);
886 rulings.push(station.1);
887 }
888 }
889 };
890 extend(&mut hinges, &mut rulings, true);
891 extend(&mut hinges, &mut rulings, false);
892 } else {
893 hinges.push(hinges[0]);
894 rulings.push(rulings[0]);
895 }
896 for edge in [s_lo, s_hi] {
897 for i in 0..hinges.len() - 1 {
898 let step = (hinges[i + 1] + rulings[i + 1] * edge) - (hinges[i] + rulings[i] * edge);
899 if step.dot(hinges[i + 1] - hinges[i]) <= 0.0 {
900 ogeom_bail!(
901 Construction,
902 "the draft folds the wall onto itself inside the drafted \
903 window; refused; see docs/PARITY.md, offset.draft"
904 );
905 }
906 }
907 }
908 let params: Vec<f64> = {
922 let mut out = Vec::with_capacity(hinges.len());
923 let mut total = 0.0;
924 out.push(0.0);
925 for pair in hinges.windows(2) {
926 total += pair[0].distance(pair[1]);
927 out.push(total);
928 }
929 if total > 0.0 {
930 for t in &mut out {
931 *t /= total;
932 }
933 }
934 if let Some(last) = out.last_mut() {
935 *last = 1.0;
936 }
937 out
938 };
939 let reach = s_lo.abs().max(s_hi.abs()).max(1.0);
940 let fit_target = (tol.confusion() * 1e3).max(1e-4);
941 let hinge_fit = ogeom_geom::fit::fit_points_at(¶ms, &hinges, 3, fit_target, tol)?;
942 let tips: Vec<Point> = hinges.iter().zip(&rulings).map(|(h, r)| *h + *r).collect();
943 let tip_fit = ogeom_geom::fit::fit_points_at(¶ms, &tips, 3, fit_target / reach, tol)?;
944 if !hinge_fit.met || !tip_fit.met {
945 ogeom_bail!(
946 NotDone,
947 "the drafted wall's hinge fit reached {} and its rulings' {} against \
948 a target of {fit_target}",
949 hinge_fit.error,
950 tip_fit.error * reach
951 );
952 }
953 let (mut hinge_curve, mut tip_curve) = (hinge_fit.curve, tip_fit.curve);
954 for (value, count) in tip_curve.knots().distinct() {
955 let have = hinge_curve.knots().multiplicity_of(value);
956 if count > have {
957 hinge_curve = hinge_curve.with_knot_inserted(value, count - have, tol)?;
958 }
959 }
960 for (value, count) in hinge_curve.knots().distinct() {
961 let have = tip_curve.knots().multiplicity_of(value);
962 if count > have {
963 tip_curve = tip_curve.with_knot_inserted(value, count - have, tol)?;
964 }
965 }
966 let (hc, tc) = (hinge_curve.control_points(), tip_curve.control_points());
967 if hc.len() != tc.len() {
968 ogeom_bail!(
969 Construction,
970 "the hinge and its rulings did not share a knot vector"
971 );
972 }
973 let mut net: Vec<Point> = Vec::with_capacity(hc.len() * 2);
974 for (h, t) in hc.iter().zip(tc) {
975 let (h, d) = (h.point(), t.point() - h.point());
976 net.push(h + d * s_lo);
977 net.push(h + d * s_hi);
978 }
979 let grid = ogeom_math::ControlGrid::new(net, hc.len(), 2)?;
980 let u_knots = if closed {
984 let ((ua, ub), _) = surface.domain();
985 hinge_curve.knots().reparameterized(ua, ub)?
986 } else {
987 hinge_curve.knots().clone()
988 };
989 let v_knots = ogeom_math::KnotVector::clamped_uniform(1, 2)?.reparameterized(s_lo, s_hi)?;
990 Ok(ogeom_geom::BSplineSurface::new(u_knots, v_knots, &grid, tol)?.into())
991}
992
993fn outward_sign(
1002 model: &Model,
1003 solid: &Shape,
1004 face: &Shape,
1005 surface: &SurfaceGeometry,
1006 tol: Tolerances,
1007) -> OgeomResult<f64> {
1008 use ogeom_algo::Containment;
1009 let mesh = ogeom_mesh::triangulate_face(model, face, ogeom_mesh::Deflection::default(), tol)?;
1013 let mut at = None;
1014 let mut largest = 0.0_f64;
1015 for t in &mesh.triangles {
1016 let [a, b, c] = [
1017 mesh.positions[t[0] as usize],
1018 mesh.positions[t[1] as usize],
1019 mesh.positions[t[2] as usize],
1020 ];
1021 let area = (b - a).cross(c - a).magnitude();
1022 if area > largest {
1023 largest = area;
1024 let params = [
1025 mesh.parameters[t[0] as usize],
1026 mesh.parameters[t[1] as usize],
1027 mesh.parameters[t[2] as usize],
1028 ];
1029 at = Some((
1030 (params[0].0 + params[1].0 + params[2].0) / 3.0,
1031 (params[0].1 + params[1].1 + params[2].1) / 3.0,
1032 ));
1033 }
1034 }
1035 let Some((um, vm)) = at else {
1036 ogeom_bail!(Construction, "the drafted face has no interior to probe");
1037 };
1038 let p = surface.point_at(um, vm, tol)?;
1039 let (du, dv) = surface.d1_at(um, vm, tol)?;
1040 let n = du.cross(dv);
1041 let m = n.magnitude();
1042 if m <= tol.confusion() {
1043 ogeom_bail!(Construction, "the face has no normal at its midpoint");
1044 }
1045 let n = n / m;
1046 let scale = largest.sqrt().max(tol.confusion() * 1e3);
1047 for eps_scale in [1e-3, 1e-2, 5e-2] {
1048 let eps = scale * eps_scale;
1049 let deflection = ogeom_mesh::Deflection {
1050 chord: (eps * 0.1).max(1e-4),
1051 ..ogeom_mesh::Deflection::default()
1052 };
1053 let ahead = ogeom_algo::classify_in_solid(model, solid, p + n * eps, deflection, tol)?;
1054 let behind = ogeom_algo::classify_in_solid(model, solid, p - n * eps, deflection, tol)?;
1055 match (ahead, behind) {
1056 (Containment::Out, Containment::In) => return Ok(1.0),
1057 (Containment::In, Containment::Out) => return Ok(-1.0),
1058 _ => {}
1059 }
1060 }
1061 ogeom_bail!(
1062 Construction,
1063 "cannot read which side of the drafted face holds material; the wall is thinner than the probe can resolve"
1064 )
1065}
1066
1067fn meet(a: Plane, b: Plane, along: Vector, tol: Tolerances) -> OgeomResult<Point> {
1069 let rows = [a.normal().vector(), b.normal().vector(), along];
1070 let rhs = [
1071 rows[0].dot(a.origin().to_vector()),
1072 rows[1].dot(b.origin().to_vector()),
1073 along.dot(Point::midpoint(a.origin(), b.origin()).to_vector()),
1074 ];
1075 let det = rows[0].dot(rows[1].cross(rows[2]));
1076 if det.abs() <= tol.confusion() {
1077 ogeom_bail!(Construction, "the two planes do not meet in a line");
1078 }
1079 Ok(Point::ORIGIN
1080 + (rows[1].cross(rows[2]) * rhs[0]
1081 + rows[2].cross(rows[0]) * rhs[1]
1082 + rows[0].cross(rows[1]) * rhs[2])
1083 / det)
1084}