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 match surface {
100 SurfaceGeometry::Cylinder(c) => {
101 let cylinder = c.cylinder();
102 let (_, (v0, v1)) = surface.domain();
103 turned.push((
104 face.node(),
105 revolved_draft(
106 cylinder.frame(),
107 cylinder.radius(),
108 0.0,
109 (v0, v1),
110 sign_of(face),
111 neutral,
112 pull,
113 angle,
114 tol,
115 )?,
116 ));
117 continue;
118 }
119 SurfaceGeometry::Cone(co) => {
120 let cone = co.cone();
121 let (_, (v0, v1)) = surface.domain();
122 turned.push((
123 face.node(),
124 revolved_draft(
125 cone.frame(),
126 cone.reference_radius(),
127 cone.half_angle(),
128 (v0, v1),
129 sign_of(face),
130 neutral,
131 pull,
132 angle,
133 tol,
134 )?,
135 ));
136 continue;
137 }
138 SurfaceGeometry::Extrusion(e) => {
139 turned.push((
140 face.node(),
141 extruded_draft(
142 e,
143 surface.domain(),
144 sign_of(face),
145 neutral,
146 pull,
147 angle,
148 tol,
149 )?,
150 ));
151 continue;
152 }
153 SurfaceGeometry::Plane(_) => {}
154 _ => ogeom_bail!(
155 Construction,
156 "drafting a face that is neither planar, a wall of \
157 revolution nor an extruded wall needs a support the \
158 rebuild cannot turn — docs/PARITY.md, offset.draft"
159 ),
160 }
161 let SurfaceGeometry::Plane(p) = surface else {
162 unreachable!("the match above let only planes through");
163 };
164 let plane = p.plane();
165 let ((u0, u1), (v0, v1)) = surface.domain();
166 let outward = plane.normal().vector() * sign;
169
170 let along = plane.normal().vector().cross(neutral.normal().vector());
172 let magnitude = along.magnitude();
173 if magnitude <= tol.angular() {
174 ogeom_bail!(
175 Construction,
176 "a face parallel to the neutral plane has no line to turn \
177 about"
178 );
179 }
180 let along = along / magnitude;
181 let hinge = meet(plane, neutral, along, tol)?;
182
183 let axis = ogeom_math::Axis::new(hinge, Direction::new(along, tol)?);
188 let mut candidates = Vec::with_capacity(2);
189 for sense in [1.0, -1.0] {
190 let turn = Transform::rotation(axis, angle.abs() * sense);
191 candidates.push((sense, turn.apply_vector(outward).dot(pull.vector())));
192 }
193 let leaning = candidates
194 .iter()
195 .copied()
196 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal))
197 .map_or(1.0, |(sense, _)| sense);
198 let turn = Transform::rotation(axis, angle * leaning);
199 let moved_normal = Direction::new(turn.apply_vector(plane.normal().vector()), tol)?;
200 let tilted = Plane::new(Frame::new(
201 hinge,
202 moved_normal,
203 Direction::new(along, tol)?,
204 tol,
205 )?);
206 let grow = (u1 - u0).abs().max((v1 - v0).abs()).mul_add(0.5, 1.0) * angle.abs().tan()
209 + tol.confusion();
210 turned.push((
211 face.node(),
212 PlaneSurface::over(tilted, (u0 - grow, u1 + grow), (v0 - grow, v1 + grow))?.into(),
213 ));
214 }
215
216 rebuilt(
217 model,
218 solid,
219 &|_| 0.0,
220 &|face| {
221 turned
222 .iter()
223 .find(|(node, _)| *node == face.node())
224 .map(|(_, surface)| surface.clone())
225 },
226 tol,
227 )
228}
229
230#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
234fn revolved_draft(
235 frame: Frame,
236 reference_radius: f64,
237 half_angle: f64,
238 window: (f64, f64),
239 sign: f64,
240 neutral: Plane,
241 pull: Direction,
242 angle: f64,
243 tol: Tolerances,
244) -> OgeomResult<SurfaceGeometry> {
245 use ogeom_geom::ConeSurface;
246
247 let axis_dir = frame.z().vector();
248 let along = axis_dir.dot(neutral.normal().vector());
249 if (along.abs() - 1.0).abs() > tol.angular().max(1e-9) {
250 ogeom_bail!(
251 Construction,
252 "a wall of revolution drafts about a neutral plane square to \
253 its axis; the oblique neutral needs the general machinery — \
254 docs/PARITY.md, offset.draft"
255 );
256 }
257 let height = -neutral.signed_distance_to(frame.origin()) * along.signum();
260 let neutral_point = frame.origin() + axis_dir * height;
261 let neutral_radius = half_angle.tan().mul_add(height, reference_radius);
262 if neutral_radius <= tol.confusion() {
263 ogeom_bail!(
264 Construction,
265 "the wall has no radius left at the neutral plane to hold"
266 );
267 }
268 let hinge_frame = Frame::new(neutral_point, frame.z(), frame.x(), tol)?;
269
270 let mut best: Option<(f64, f64)> = None;
274 for sense in [1.0_f64, -1.0] {
275 let probe = half_angle + angle.abs() * sense;
278 let candidate = half_angle + angle * sense;
279 if probe.abs() <= tol.angular()
280 || probe.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular()
281 || candidate.abs() <= tol.angular()
282 || candidate.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular()
283 {
284 continue;
285 }
286 let cone = ogeom_math::Cone::new(hinge_frame, neutral_radius, probe, tol)?;
287 let surface: SurfaceGeometry = ConeSurface::new(cone, (-1.0, 1.0))?.into();
288 let (du, dv) = surface.d1_at(0.0, 1.0, tol)?;
289 let n = du.cross(dv);
290 let outward = n / n.magnitude() * sign;
291 let lean = outward.dot(pull.vector());
292 if best.as_ref().is_none_or(|(_, held)| lean > *held) {
293 best = Some((candidate, lean));
294 }
295 }
296 let Some((leaned, _)) = best else {
297 ogeom_bail!(
298 Construction,
299 "a draft of {angle} radians flattens the wall or swallows it"
300 );
301 };
302 let cone = ogeom_math::Cone::new(hinge_frame, neutral_radius, leaned, tol)?;
303
304 let shift = height;
307 let grow = (window.1 - window.0).abs().mul_add(0.1, 1.0);
308 let (w0, w1) = (window.0 - shift - grow, window.1 - shift + grow);
309 let apex_height = -neutral_radius / leaned.tan();
310 if apex_height > w0 && apex_height < w1 {
311 ogeom_bail!(
312 Construction,
313 "the draft swallows the drafted face's own apex"
314 );
315 }
316 Ok(ConeSurface::new(cone, (w0, w1))?.into())
317}
318
319#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
331fn extruded_draft(
332 extrusion: &ogeom_geom::ExtrusionSurface,
333 window: ((f64, f64), (f64, f64)),
334 sign: f64,
335 neutral: Plane,
336 pull: Direction,
337 angle: f64,
338 tol: Tolerances,
339) -> OgeomResult<SurfaceGeometry> {
340 use ogeom_geom::Curve3d as _;
341 let ((u0, u1), (v0, v1)) = window;
342 let d = extrusion.direction().vector();
343 let n = neutral.normal().vector();
344 let den = n.dot(d);
345 if den.abs() <= tol.angular() {
346 ogeom_bail!(
347 Construction,
348 "the neutral plane runs along the wall's rulings; there is no \
349 hinge to turn about"
350 );
351 }
352 let curve = extrusion.curve();
353 let o = neutral.origin().to_vector();
354 let height_at = |c: Point| n.dot(o - c.to_vector()) / den;
357 let hinge_tangent = |cd: Vector| cd - d * (n.dot(cd) / den);
358
359 let um = f64::midpoint(u0, u1);
364 let cm = curve.point_at(um, tol)?;
365 let cdm = curve.d1_at(um, tol)?;
366 let hinge_m = cm + d * height_at(cm);
367 let tangent_m = Direction::new(hinge_tangent(cdm), tol)?;
368 let outward = {
369 let nw = cdm.cross(d);
370 nw / nw.magnitude() * sign
371 };
372 let axis_m = ogeom_math::Axis::new(hinge_m, tangent_m);
373 let mut leaning = 1.0;
374 let mut best = f64::NEG_INFINITY;
375 for sense in [1.0_f64, -1.0] {
376 let turn = Transform::rotation(axis_m, angle.abs() * sense);
377 let lean = turn.apply_vector(outward).dot(pull.vector());
378 if lean > best {
379 best = lean;
380 leaning = sense;
381 }
382 }
383 let theta = angle * leaning;
384
385 const ALONG: usize = 65;
388 let mut hinges: Vec<Point> = Vec::with_capacity(ALONG);
389 let mut rulings: Vec<Vector> = Vec::with_capacity(ALONG);
390 let (mut s_lo, mut s_hi) = (f64::INFINITY, f64::NEG_INFINITY);
391 for i in 0..ALONG {
392 #[allow(clippy::cast_precision_loss)]
393 let u = u0 + (u1 - u0) * (i as f64) / ((ALONG - 1) as f64);
394 let c = curve.point_at(u, tol)?;
395 let cd = curve.d1_at(u, tol)?;
396 let h = height_at(c);
397 let hinge = c + d * h;
398 let tangent = Direction::new(hinge_tangent(cd), tol)?;
399 let turn = Transform::rotation(ogeom_math::Axis::new(hinge, tangent), theta);
400 hinges.push(hinge);
401 rulings.push(turn.apply_vector(d));
402 s_lo = s_lo.min(v0 - h);
403 s_hi = s_hi.max(v1 - h);
404 }
405 let grow = (u1 - u0).abs().max((v1 - v0).abs()).mul_add(0.5, 1.0) * angle.abs().tan()
410 + tol.confusion();
411 let (s_lo, s_hi) = (s_lo - grow, s_hi + grow);
412 {
413 let extend = |hinges: &mut Vec<Point>, rulings: &mut Vec<Vector>, front: bool| {
416 let (i0, i1, i2) = if front {
417 (0, 1, 2)
418 } else {
419 let n = hinges.len();
420 (n - 1, n - 2, n - 3)
421 };
422 let d1 = hinges[i0] - hinges[i1];
423 let d2 = (hinges[i0] - hinges[i1]) - (hinges[i1] - hinges[i2]);
424 let r1 = rulings[i0] - rulings[i1];
425 let steps = (grow / d1.magnitude().max(tol.confusion())).ceil().max(2.0);
426 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
427 let steps = (steps as usize).min(16);
428 for k in 1..=steps {
429 #[allow(clippy::cast_precision_loss)]
430 let k = k as f64;
431 let station = (
432 hinges[i0] + d1 * k + d2 * (k * (k + 1.0) / 2.0),
433 rulings[i0] + r1 * k,
434 );
435 if front {
436 hinges.insert(0, station.0);
437 rulings.insert(0, station.1);
438 } else {
439 hinges.push(station.0);
440 rulings.push(station.1);
441 }
442 }
443 };
444 extend(&mut hinges, &mut rulings, true);
445 extend(&mut hinges, &mut rulings, false);
446 }
447 let along_total = hinges.len();
448
449 for edge in [s_lo, s_hi] {
452 for i in 0..along_total - 1 {
453 let step = (hinges[i + 1] + rulings[i + 1] * edge) - (hinges[i] + rulings[i] * edge);
454 if step.dot(hinges[i + 1] - hinges[i]) <= 0.0 {
455 ogeom_bail!(
456 Construction,
457 "the draft folds the wall onto itself inside the drafted \
458 window; refused — docs/PARITY.md, offset.draft"
459 );
460 }
461 }
462 }
463
464 const ACROSS: usize = 9;
467 let rows: Vec<Vec<Point>> = (0..ACROSS)
468 .map(|j| {
469 #[allow(clippy::cast_precision_loss)]
470 let s = s_lo + (s_hi - s_lo) * (j as f64) / ((ACROSS - 1) as f64);
471 (0..along_total)
472 .map(|i| hinges[i] + rulings[i] * s)
473 .collect()
474 })
475 .collect();
476 let fit_target = (tol.confusion() * 1e3).max(1e-4);
477 let fitted = ogeom_geom::fit::fit_surface_grid(&rows, 3, fit_target, tol)?;
478 if !fitted.met {
479 ogeom_bail!(
480 NotDone,
481 "the drafted wall's fit reached {} against a target of {fit_target}",
482 fitted.error
483 );
484 }
485 Ok(fitted.curve.into())
486}
487
488fn outward_sign(
497 model: &Model,
498 solid: &Shape,
499 face: &Shape,
500 surface: &SurfaceGeometry,
501 tol: Tolerances,
502) -> OgeomResult<f64> {
503 use ogeom_algo::Containment;
504 let mesh = ogeom_mesh::triangulate_face(model, face, ogeom_mesh::Deflection::default(), tol)?;
508 let mut at = None;
509 let mut largest = 0.0_f64;
510 for t in &mesh.triangles {
511 let [a, b, c] = [
512 mesh.positions[t[0] as usize],
513 mesh.positions[t[1] as usize],
514 mesh.positions[t[2] as usize],
515 ];
516 let area = (b - a).cross(c - a).magnitude();
517 if area > largest {
518 largest = area;
519 let params = [
520 mesh.parameters[t[0] as usize],
521 mesh.parameters[t[1] as usize],
522 mesh.parameters[t[2] as usize],
523 ];
524 at = Some((
525 (params[0].0 + params[1].0 + params[2].0) / 3.0,
526 (params[0].1 + params[1].1 + params[2].1) / 3.0,
527 ));
528 }
529 }
530 let Some((um, vm)) = at else {
531 ogeom_bail!(Construction, "the drafted face has no interior to probe");
532 };
533 let p = surface.point_at(um, vm, tol)?;
534 let (du, dv) = surface.d1_at(um, vm, tol)?;
535 let n = du.cross(dv);
536 let m = n.magnitude();
537 if m <= tol.confusion() {
538 ogeom_bail!(Construction, "the face has no normal at its midpoint");
539 }
540 let n = n / m;
541 let scale = largest.sqrt().max(tol.confusion() * 1e3);
542 for eps_scale in [1e-3, 1e-2, 5e-2] {
543 let eps = scale * eps_scale;
544 let deflection = ogeom_mesh::Deflection {
545 chord: (eps * 0.1).max(1e-4),
546 ..ogeom_mesh::Deflection::default()
547 };
548 let ahead = ogeom_algo::classify_in_solid(model, solid, p + n * eps, deflection, tol)?;
549 let behind = ogeom_algo::classify_in_solid(model, solid, p - n * eps, deflection, tol)?;
550 match (ahead, behind) {
551 (Containment::Out, Containment::In) => return Ok(1.0),
552 (Containment::In, Containment::Out) => return Ok(-1.0),
553 _ => {}
554 }
555 }
556 ogeom_bail!(
557 Construction,
558 "cannot read which side of the drafted face holds material; the wall is thinner than the probe can resolve"
559 )
560}
561
562fn meet(a: Plane, b: Plane, along: Vector, tol: Tolerances) -> OgeomResult<Point> {
564 let rows = [a.normal().vector(), b.normal().vector(), along];
565 let rhs = [
566 rows[0].dot(a.origin().to_vector()),
567 rows[1].dot(b.origin().to_vector()),
568 along.dot(Point::midpoint(a.origin(), b.origin()).to_vector()),
569 ];
570 let det = rows[0].dot(rows[1].cross(rows[2]));
571 if det.abs() <= tol.confusion() {
572 ogeom_bail!(Construction, "the two planes do not meet in a line");
573 }
574 Ok(Point::ORIGIN
575 + (rows[1].cross(rows[2]) * rhs[0]
576 + rows[2].cross(rows[0]) * rhs[1]
577 + rows[0].cross(rows[1]) * rhs[2])
578 / det)
579}