1use super::*;
2use crate::{KernelRefusal, KernelStage, OrRefuse};
3use crate::offset_retrim::{rebuild_loop_pcurves, retrim_planar_face};
4use crate::{RevolutionFrame, Vec4};
5use super::face_move::{carrier_ruled, retrim_ruled_face};
6use super::face_rotate::rotation_about;
7
8const OP: &str = "draft_faces";
52
53const CONSUMES_FACE: &str = "draft_consumes_face";
57
58#[derive(Clone)]
60enum Carrier {
61 Plane { origin: Vec3, normal: Vec3 },
63 Ruled { frame: RevolutionFrame, rho0: f64, rho1: f64, height: f64 },
67}
68
69impl Carrier {
70 fn residual(&self, point: Vec3) -> f64 {
71 match self {
72 Carrier::Plane { origin, normal } => point.sub(*origin).dot(*normal),
73 Carrier::Ruled { frame, rho0, rho1, height } => {
74 let w = point.sub(frame.origin);
75 let h = w.dot(frame.axis);
76 let radial = w.sub(frame.axis.scale(h)).length();
77 radial - (rho0 + (rho1 - rho0) * h / height)
78 }
79 }
80 }
81}
82
83#[derive(Clone)]
85enum Draft {
86 Plane { old: Plane, outward: Vec3, rotation: AffineTransform },
89 Cylinder { axis_point: Vec3, radius: f64, sign: f64 },
92}
93
94fn outward_sign(face: &FaceRecord) -> f64 {
96 if face.same_sense {
97 1.0
98 } else {
99 -1.0
100 }
101}
102
103fn frame_about(origin: Vec3, axis: Vec3) -> Result<RevolutionFrame, KernelRefusal> {
105 let x_axis = axis.perpendicular().or_refuse(KernelStage::Classify, "perpendicular")?;
106 let y_axis = axis.cross(x_axis).normalized().or_refuse(KernelStage::Classify, "normalized")?;
107 Ok(RevolutionFrame { origin, axis, x_axis, y_axis })
108}
109
110fn vertical_cylinder(surface: &NurbsSurface, pull: Vec3, tolerance: f64) -> Option<(Vec3, f64)> {
116 let [u0, u1] = surface.domain_u().ok()?;
117 let [v0, v1] = surface.domain_v().ok()?;
118 let ex = pull.perpendicular().ok()?;
119 let ey = pull.cross(ex).normalized().ok()?;
120 let mut samples: Vec<[f64; 2]> = Vec::with_capacity(25);
121 for i in 0..5 {
122 for j in 0..5 {
123 let u = u0 + (u1 - u0) * (0.05 + 0.9 * i as f64 / 4.0);
124 let v = v0 + (v1 - v0) * (0.05 + 0.9 * j as f64 / 4.0);
125 let normal = surface.normal(u, v).ok()?;
126 if normal.dot(pull).abs() > 1e-7 {
127 return None;
128 }
129 let point = surface.evaluate(u, v).ok()?;
130 samples.push([point.dot(ex), point.dot(ey)]);
131 }
132 }
133 let distance = |a: [f64; 2], b: [f64; 2]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt();
134 let a = samples[0];
135 let b = *samples.iter().max_by(|p, q| distance(a, **p).total_cmp(&distance(a, **q)))?;
136 let area = |p: [f64; 2]| ((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])).abs();
137 let c = *samples.iter().max_by(|p, q| area(**p).total_cmp(&area(**q)))?;
138 if area(c) <= 1e-9 * distance(a, b).powi(2).max(1e-30) {
139 return None; }
141 let (bx, by) = (b[0] - a[0], b[1] - a[1]);
142 let (cx, cy) = (c[0] - a[0], c[1] - a[1]);
143 let det = 2.0 * (bx * cy - by * cx);
144 let ux = (cy * (bx * bx + by * by) - by * (cx * cx + cy * cy)) / det;
145 let uy = (bx * (cx * cx + cy * cy) - cx * (bx * bx + by * by)) / det;
146 let center = [a[0] + ux, a[1] + uy];
147 let radius = (ux * ux + uy * uy).sqrt();
148 if samples.iter().any(|p| (distance(*p, center) - radius).abs() > tolerance) {
149 return None;
150 }
151 Some((ex.scale(center[0]).add(ey.scale(center[1])), radius))
152}
153
154fn carrier_of(
157 solid: &BrepSolid,
158 face_lookup: &HashMap<u64, (usize, usize)>,
159 face_id: u64,
160 pull: Vec3,
161 plane_tolerance: f64,
162) -> Option<Carrier> {
163 let &(shell, index) = face_lookup.get(&face_id)?;
164 let surface = &solid.shells[shell].faces[index].surface;
165 if let Ok(plane) = plane_of_surface(surface, plane_tolerance, OP) {
166 return Some(Carrier::Plane { origin: plane.origin, normal: plane.normal });
167 }
168 if let Some((frame, rho0, rho1, height)) = carrier_ruled(solid, face_lookup, face_id) {
169 return Some(Carrier::Ruled { frame, rho0, rho1, height });
170 }
171 let (axis_point, radius) = vertical_cylinder(surface, pull, plane_tolerance)?;
172 let frame = frame_about(axis_point, pull).ok()?;
173 Some(Carrier::Ruled { frame, rho0: radius, rho1: radius, height: 1.0 })
174}
175
176fn swept_azimuth(frame: &RevolutionFrame, curve: &NurbsCurve, t0: f64, t1: f64) -> Result<f64, KernelRefusal> {
178 let azimuth = |point: Vec3| {
179 let w = point.sub(frame.origin);
180 w.dot(frame.y_axis).atan2(w.dot(frame.x_axis))
181 };
182 let steps = 64;
183 let mut previous = azimuth(curve.evaluate(t0).or_refuse(KernelStage::Refine, "evaluate")?);
184 let mut total = 0.0;
185 for step in 1..=steps {
186 let t = t0 + (t1 - t0) * step as f64 / steps as f64;
187 let next = azimuth(curve.evaluate(t).or_refuse(KernelStage::Refine, "evaluate")?);
188 let mut delta = next - previous;
189 while delta > std::f64::consts::PI {
190 delta -= std::f64::consts::TAU;
191 }
192 while delta < -std::f64::consts::PI {
193 delta += std::f64::consts::TAU;
194 }
195 total += delta;
196 previous = next;
197 }
198 Ok(total)
199}
200
201fn line_meets(carrier: &Carrier, point: Vec3, direction: Vec3, near: Vec3) -> Option<Vec3> {
203 match carrier {
204 Carrier::Plane { origin, normal } => {
205 let denominator = normal.dot(direction);
206 if denominator.abs() <= 1e-12 {
207 return None;
208 }
209 let t = normal.dot(origin.sub(point)) / denominator;
210 Some(point.add(direction.scale(t)))
211 }
212 Carrier::Ruled { frame, rho0, rho1, height } => {
213 let k = (rho1 - rho0) / height;
214 let w0 = point.sub(frame.origin);
215 let h0 = w0.dot(frame.axis);
216 let q0 = w0.sub(frame.axis.scale(h0));
217 let h1 = direction.dot(frame.axis);
218 let q1 = direction.sub(frame.axis.scale(h1));
219 let r0 = rho0 + k * h0;
220 let a = q1.dot(q1) - k * k * h1 * h1;
221 let b = 2.0 * (q0.dot(q1) - r0 * k * h1);
222 let c = q0.dot(q0) - r0 * r0;
223 let mut roots = Vec::new();
224 if a.abs() <= 1e-14 * (b.abs() + c.abs()).max(1e-300) {
225 if b.abs() > 0.0 {
226 roots.push(-c / b);
227 }
228 } else {
229 let disc = b * b - 4.0 * a * c;
230 if disc < 0.0 {
231 return None;
232 }
233 let root = disc.sqrt();
234 roots.push((-b + root) / (2.0 * a));
235 roots.push((-b - root) / (2.0 * a));
236 }
237 roots
238 .into_iter()
239 .filter(|t| r0 + k * h1 * t >= 0.0)
240 .map(|t| point.add(direction.scale(t)))
241 .min_by(|p, q| p.sub(near).length().total_cmp(&q.sub(near).length()))
242 }
243 }
244}
245
246fn planes_meet(planes: &[(Vec3, Vec3)]) -> Option<Vec3> {
248 let mut m = [[0.0f64; 3]; 3];
249 let mut rhs = [0.0f64; 3];
250 for (origin, normal) in planes {
251 let n = [normal.x, normal.y, normal.z];
252 let b = normal.dot(*origin);
253 for r in 0..3 {
254 for c in 0..3 {
255 m[r][c] += n[r] * n[c];
256 }
257 rhs[r] += n[r] * b;
258 }
259 }
260 let det = |m: &[[f64; 3]; 3]| {
261 m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
262 - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
263 + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
264 };
265 let d = det(&m);
266 let trace = m[0][0] + m[1][1] + m[2][2];
267 if d.abs() <= 1e-10 * trace.powi(3) {
268 return None;
269 }
270 let mut solution = [0.0f64; 3];
271 for column in 0..3 {
272 let mut replaced = m;
273 for row in 0..3 {
274 replaced[row][column] = rhs[row];
275 }
276 solution[column] = det(&replaced) / d;
277 }
278 Some(Vec3::new(solution[0], solution[1], solution[2]))
279}
280
281pub fn draft_faces(
293 solid: &BrepSolid,
294 face_ids: &[u64],
295 pull_direction: Vec3,
296 neutral_point: Vec3,
297 angle_radians: f64,
298) -> Result<BrepSolid, KernelRefusal> {
299 match draft_faces_in_place(solid, face_ids, pull_direction, neutral_point, angle_radians) {
300 Err(refusal)
301 if matches!(&refusal.class, crate::RefusalClass::UnsupportedGeometry { what } if what == CONSUMES_FACE) =>
302 {
303 draft_convex_polyhedron(solid, face_ids, pull_direction, neutral_point, angle_radians)
304 .map_err(|convex| {
305 KernelRefusal::unsupported(KernelStage::Classify, CONSUMES_FACE, format!(
306 "{OP}: the draft changes the body's topology ({refusal}), and the convex \
307 route that builds such a draft cannot take this body ({convex}) — refusing"
308 ))
309 })
310 }
311 other => other,
312 }
313}
314
315fn check_inputs(
316 face_ids: &[u64],
317 pull_direction: Vec3,
318 neutral_point: Vec3,
319 angle_radians: f64,
320) -> Result<Vec3, KernelRefusal> {
321 let finite = |v: Vec3| v.x.is_finite() && v.y.is_finite() && v.z.is_finite();
322 if !finite(pull_direction) {
323 return Err(KernelRefusal::input(KernelStage::Collect, "pull_direction", format!("{OP}: the pull direction must be finite")));
324 }
325 if !finite(neutral_point) {
326 return Err(KernelRefusal::input(KernelStage::Collect, "neutral_point", format!("{OP}: the neutral point must be finite")));
327 }
328 if !angle_radians.is_finite() || angle_radians.abs() >= 89.0f64.to_radians() {
329 return Err(KernelRefusal::input(KernelStage::Collect, "angle", format!(
330 "{OP}: the draft angle must be finite and less than 89° either way (got {angle_radians})"
331 )));
332 }
333 if face_ids.is_empty() {
334 return Err(KernelRefusal::input(KernelStage::Collect, "selection", format!("{OP}: no faces selected")));
335 }
336 pull_direction.normalized().map_err(|_| {
337 KernelRefusal::input(KernelStage::Collect, "pull_direction", format!("{OP}: the pull direction has zero length"))
338 })
339}
340
341fn draft_faces_in_place(
342 solid: &BrepSolid,
343 face_ids: &[u64],
344 pull_direction: Vec3,
345 neutral_point: Vec3,
346 angle_radians: f64,
347) -> Result<BrepSolid, KernelRefusal> {
348 let pull = check_inputs(face_ids, pull_direction, neutral_point, angle_radians)?;
349 let scale = solid_model_scale(solid);
350 let tolerance = (scale * 1e-7).max(1e-9);
351 let plane_tolerance = (scale * 1e-6).max(1e-7);
352 let accept = 10.0 * plane_tolerance;
353 let (sin, cos) = angle_radians.sin_cos();
354 let tan = angle_radians.tan();
355 let height_of = |point: Vec3| point.sub(neutral_point).dot(pull);
356
357 let mut face_lookup: HashMap<u64, (usize, usize)> = HashMap::default();
358 for (shell_index, shell) in solid.shells.iter().enumerate() {
359 for (face_index, face) in shell.faces.iter().enumerate() {
360 face_lookup.entry(face.id).or_insert((shell_index, face_index));
361 }
362 }
363 let selected: HashSet<u64> = face_ids.iter().copied().collect();
364 for &face_id in &selected {
365 if !face_lookup.contains_key(&face_id) {
366 return Err(KernelRefusal::input(KernelStage::Collect, "face_id", format!("{OP}: no face with id {face_id}")));
367 }
368 }
369 if angle_radians == 0.0 {
370 return Ok(solid.clone());
371 }
372
373 let mut drafts: HashMap<u64, Draft> = HashMap::default();
375 let mut carriers: HashMap<u64, Carrier> = HashMap::default();
376 for &face_id in &selected {
377 let (shell, index) = face_lookup[&face_id];
378 let face = &solid.shells[shell].faces[index];
379 let sign = outward_sign(face);
380 if let Ok(old) = plane_of_surface(&face.surface, plane_tolerance, OP) {
381 let outward = old.normal.scale(sign);
382 if outward.dot(pull).abs() > 1e-9 {
383 return Err(KernelRefusal::unsupported(KernelStage::Classify, "draft_plane_not_parallel", format!(
384 "{OP}: face {face_id} is a plane that is not parallel to the pull direction \
385 (its normal makes {:.6}° with the pull); a draft turns a face that runs along \
386 the pull — refusing",
387 outward.dot(pull).clamp(-1.0, 1.0).acos().to_degrees()
388 )));
389 }
390 let hinge = outward.cross(pull).normalized().or_refuse(KernelStage::Classify, "hinge")?;
392 let on_hinge = old.origin.add(pull.scale(-height_of(old.origin)));
393 let rotation = rotation_about(on_hinge, hinge, angle_radians).or_refuse(KernelStage::Classify, "rotation")?;
394 let turned = super::face_rotate::rotate_direction(&rotation, old.normal);
395 carriers.insert(face_id, Carrier::Plane { origin: on_hinge, normal: turned });
396 drafts.insert(face_id, Draft::Plane { old, outward, rotation });
397 continue;
398 }
399 let Some((axis_point, radius)) = vertical_cylinder(&face.surface, pull, plane_tolerance) else {
400 return Err(KernelRefusal::unsupported(KernelStage::Classify, "draft_carrier_kind", format!(
401 "{OP}: face {face_id} is neither a plane parallel to the pull direction nor a \
402 cylinder whose axis is the pull direction; a draft turns those into a tilted plane \
403 and a cone, and has no drafted carrier for this one — refusing"
404 )));
405 };
406 let axis_point = axis_point.add(pull.scale(height_of(axis_point) * -1.0));
407 let [u0, u1] = face.surface.domain_u().or_refuse(KernelStage::Classify, "domain_u")?;
409 let [v0, v1] = face.surface.domain_v().or_refuse(KernelStage::Classify, "domain_v")?;
410 let (um, vm) = ((u0 + u1) * 0.5, (v0 + v1) * 0.5);
411 let at = face.surface.evaluate(um, vm).or_refuse(KernelStage::Classify, "evaluate")?;
412 let normal = face.surface.normal(um, vm).or_refuse(KernelStage::Classify, "normal")?.scale(sign);
413 let radial = at.sub(axis_point);
414 let radial = radial.sub(pull.scale(radial.dot(pull)));
415 let draft_sign = if normal.dot(radial) > 0.0 { 1.0 } else { -1.0 };
416 let frame = frame_about(axis_point, pull)?;
417 let height = 0.5 * radius / tan.abs();
419 carriers.insert(face_id, Carrier::Ruled {
420 frame,
421 rho0: radius,
422 rho1: radius - draft_sign * tan * height,
423 height,
424 });
425 drafts.insert(face_id, Draft::Cylinder { axis_point, radius, sign: draft_sign });
426 }
427 let generator = |face_id: u64, point: Vec3| -> Option<Vec3> {
430 let outward = match drafts.get(&face_id)? {
431 Draft::Plane { outward, .. } => *outward,
432 Draft::Cylinder { axis_point, sign, .. } => {
433 let radial = point.sub(*axis_point);
434 radial.sub(pull.scale(radial.dot(pull))).normalized().ok()?.scale(*sign)
435 }
436 };
437 pull.scale(cos).sub(outward.scale(sin)).normalized().ok()
438 };
439
440 let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
442 for shell in &solid.shells {
443 for face in &shell.faces {
444 for loop_record in &face.loops {
445 for coedge in &loop_record.coedges {
446 faces_of_edge.entry(coedge.edge_id).or_default().push(face.id);
447 }
448 }
449 }
450 }
451 let mut edges_at: HashMap<u64, Vec<usize>> = HashMap::default();
452 for (index, edge) in solid.edges.iter().enumerate() {
453 let uses = faces_of_edge.get(&edge.id).map(Vec::len).unwrap_or(0);
454 let expected = if edge.degenerate { 1 } else { 2 };
455 if uses != expected {
456 return Err(KernelRefusal::input(KernelStage::Collect, "non_manifold", format!(
457 "{OP}: edge {} is used {uses} times (non-manifold input)", edge.id
458 )));
459 }
460 edges_at.entry(edge.start_vertex_id).or_default().push(index);
461 if edge.end_vertex_id != edge.start_vertex_id {
462 edges_at.entry(edge.end_vertex_id).or_default().push(index);
463 }
464 }
465 let touches_draft = |edge: &EdgeRecord| faces_of_edge[&edge.id].iter().any(|f| selected.contains(f));
466
467 let mut neighbours: HashSet<u64> = HashSet::default();
470 for edge in &solid.edges {
471 if touches_draft(edge) {
472 neighbours.extend(faces_of_edge[&edge.id].iter().copied());
473 }
474 }
475 for vertex_edges in edges_at.values() {
476 if vertex_edges.iter().any(|&e| touches_draft(&solid.edges[e])) {
477 for &e in vertex_edges {
478 neighbours.extend(faces_of_edge[&solid.edges[e].id].iter().copied());
479 }
480 }
481 }
482 let mut sorted_neighbours: Vec<u64> = neighbours.iter().copied().collect();
483 sorted_neighbours.sort_unstable();
484 for face_id in sorted_neighbours {
485 if carriers.contains_key(&face_id) {
486 continue;
487 }
488 let Some(carrier) = carrier_of(solid, &face_lookup, face_id, pull, plane_tolerance) else {
489 return Err(KernelRefusal::unsupported(KernelStage::Classify, "draft_neighbour_kind", format!(
490 "{OP}: face {face_id}, which meets a drafted face, is not a plane, a cylinder or \
491 a cone; the draft re-trims its neighbours by closed-form sections only — refusing"
492 )));
493 };
494 carriers.insert(face_id, carrier);
495 }
496
497 let mut new_vertex: HashMap<u64, Vec3> = HashMap::default();
499 for vertex in &solid.vertices {
500 let Some(vertex_edges) = edges_at.get(&vertex.id) else { continue };
501 let mut at: Vec<u64> = vertex_edges
502 .iter()
503 .flat_map(|&e| faces_of_edge[&solid.edges[e].id].iter().copied())
504 .collect();
505 at.sort_unstable();
506 at.dedup();
507 if !at.iter().any(|f| selected.contains(f)) {
508 continue;
509 }
510 let worst = |point: Vec3| at.iter().map(|f| carriers[f].residual(point).abs()).fold(0.0, f64::max);
511 if worst(vertex.point) <= tolerance {
512 new_vertex.insert(vertex.id, vertex.point); continue;
514 }
515 let mut lines: Vec<(Vec3, Vec3)> = Vec::new();
519 for &e in vertex_edges {
520 let edge = &solid.edges[e];
521 if edge.degenerate {
522 continue;
523 }
524 let Some((a, b)) = edge.curve.straight_segment(tolerance) else { continue };
525 let uses = &faces_of_edge[&edge.id];
526 let drafted = uses.iter().filter(|f| selected.contains(*f)).count();
527 let direction = b.sub(a);
528 if drafted == 0 {
529 lines.push((a, direction));
530 } else if drafted == uses.len() {
531 let along = direction.dot(pull);
532 if along.abs() <= 1e-9 * direction.length() {
533 continue; }
535 let crossing = a.add(direction.scale(-height_of(a) / along));
536 let mut line_direction = None;
537 if let (Some(Carrier::Plane { normal: na, .. }), Some(Carrier::Plane { normal: nb, .. })) =
538 (carriers.get(&uses[0]), carriers.get(&uses[1]))
539 {
540 let cross = na.cross(*nb);
541 if cross.length() > 1e-9 {
542 line_direction = cross.normalized().ok();
543 }
544 }
545 let line_direction = match line_direction.or_else(|| generator(uses[0], crossing)) {
546 Some(direction) => direction,
547 None => continue,
548 };
549 let probe = crossing.add(line_direction.scale(scale));
550 if uses.iter().all(|f| {
551 carriers[f].residual(crossing).abs() <= accept && carriers[f].residual(probe).abs() <= accept
552 }) {
553 lines.push((crossing, line_direction));
554 }
555 }
556 }
557 let mut best: Option<(f64, Vec3)> = None;
558 for &(point, direction) in &lines {
559 for face_id in &at {
560 let carrier = &carriers[face_id];
561 let probe = point.add(direction.scale(scale));
562 if carrier.residual(point).abs() <= tolerance && carrier.residual(probe).abs() <= tolerance {
563 continue; }
565 if let Some(candidate) = line_meets(carrier, point, direction, vertex.point) {
566 let error = worst(candidate);
567 if best.map_or(true, |(e, _)| error < e) {
568 best = Some((error, candidate));
569 }
570 }
571 }
572 }
573 if best.map_or(true, |(e, _)| e > accept) {
574 let planes: Option<Vec<(Vec3, Vec3)>> = at
575 .iter()
576 .map(|f| match &carriers[f] {
577 Carrier::Plane { origin, normal } => Some((*origin, *normal)),
578 _ => None,
579 })
580 .collect();
581 if let Some(candidate) = planes.as_deref().filter(|p| p.len() >= 3).and_then(planes_meet) {
582 let error = worst(candidate);
583 if best.map_or(true, |(e, _)| error < e) {
584 best = Some((error, candidate));
585 }
586 }
587 }
588 match best {
589 Some((error, point)) if error <= accept => {
590 new_vertex.insert(vertex.id, point);
591 }
592 _ => {
593 return Err(KernelRefusal::unsupported(KernelStage::Refine, "draft_corner", format!(
594 "{OP}: the corner at vertex {} where faces {at:?} meet has no closed-form \
595 solve on the drafted carriers (a corner must lie on an edge line both of \
596 whose faces are drafted or both fixed, or on three planes) — refusing",
597 vertex.id
598 )));
599 }
600 }
601 }
602
603 let point_of = |id: u64| -> Vec3 {
605 new_vertex
606 .get(&id)
607 .copied()
608 .unwrap_or_else(|| solid.vertices.iter().find(|v| v.id == id).map(|v| v.point).unwrap_or_default())
609 };
610 let old_point = |id: u64| -> Vec3 { solid.vertices.iter().find(|v| v.id == id).map(|v| v.point).unwrap_or_default() };
611 let mut result = solid.clone();
612 let mut changed_edges: HashSet<u64> = HashSet::default();
613 for edge in &mut result.edges {
614 if edge.degenerate {
615 continue;
616 }
617 let uses = faces_of_edge[&edge.id].clone();
618 let moved_ends = [edge.start_vertex_id, edge.end_vertex_id]
619 .iter()
620 .any(|id| point_of(*id).sub(old_point(*id)).length() > tolerance);
621 let drafted_here = uses.iter().any(|f| selected.contains(f));
622 if !drafted_here && !moved_ends {
623 continue;
624 }
625 let (start, end) = (point_of(edge.start_vertex_id), point_of(edge.end_vertex_id));
626 let mid_t = (edge.t0 + edge.t1) * 0.5;
627 let old_mid = edge.curve.evaluate(mid_t).or_refuse(KernelStage::Refine, "evaluate")?;
628 let on_new = |point: Vec3| uses.iter().all(|f| carriers.get(f).map_or(false, |c| c.residual(point).abs() <= accept));
629 if !moved_ends && on_new(old_mid) {
630 let quarter = edge.curve.evaluate(edge.t0 + (edge.t1 - edge.t0) * 0.25).or_refuse(KernelStage::Refine, "evaluate")?;
633 if on_new(quarter) {
634 continue;
635 }
636 }
637 changed_edges.insert(edge.id);
638 if let Some((a, b)) = edge.curve.straight_segment(tolerance) {
639 let old_direction = b.sub(a);
640 let new_direction = end.sub(start);
641 if new_direction.length() <= tolerance || new_direction.dot(old_direction) <= 0.0 {
642 return Err(KernelRefusal::unsupported(KernelStage::Refine, CONSUMES_FACE, format!(
643 "{OP}: the draft turns edge {} end over end (or collapses it) between faces \
644 {uses:?}: it consumes a face, a change of topology the in-place draft does \
645 not make — refusing",
646 edge.id
647 )));
648 }
649 let curve = make_line(start, end).or_refuse(KernelStage::Sew, "make_line")?;
650 let mid = curve.evaluate(0.5).or_refuse(KernelStage::Sew, "evaluate")?;
651 if !on_new(mid) {
652 return Err(KernelRefusal::internal(KernelStage::Validate, "draft_edge_off_carrier", format!(
653 "{OP}: the rebuilt straight edge {} leaves the carriers of faces {uses:?} — refusing",
654 edge.id
655 )));
656 }
657 edge.curve = curve;
658 edge.t0 = 0.0;
659 edge.t1 = 1.0;
660 continue;
661 }
662 let pair = match (carriers.get(&uses[0]), carriers.get(&uses[1])) {
664 (Some(Carrier::Plane { origin, normal }), Some(Carrier::Ruled { frame, rho0, rho1, height }))
665 | (Some(Carrier::Ruled { frame, rho0, rho1, height }), Some(Carrier::Plane { origin, normal })) => {
666 Some((*origin, *normal, frame.clone(), *rho0, *rho1, *height))
667 }
668 _ => None,
669 };
670 let Some((origin, normal, frame, rho0, rho1, height)) = pair else {
671 return Err(KernelRefusal::unsupported(KernelStage::Refine, "draft_curved_edge", format!(
672 "{OP}: curved edge {} between faces {uses:?} is not the section of a cylinder or \
673 cone by a plane, the only curved edge the draft re-intersects — refusing",
674 edge.id
675 )));
676 };
677 let sweep = swept_azimuth(&frame, &edge.curve, edge.t0, edge.t1)?;
678 let azimuth = |point: Vec3| {
679 let w = point.sub(frame.origin);
680 let a = w.dot(frame.y_axis).atan2(w.dot(frame.x_axis));
681 if a < 0.0 { a + std::f64::consts::TAU } else { a }
682 };
683 let closed = edge.start_vertex_id == edge.end_vertex_id;
684 let sweep = if closed { std::f64::consts::TAU.copysign(sweep) } else { sweep };
685 let (from, magnitude) = if sweep > 0.0 { (azimuth(start), sweep) } else { (azimuth(end), -sweep) };
686 let magnitude = if closed {
690 magnitude
691 } else {
692 let (a0, a1) = if sweep > 0.0 { (azimuth(start), azimuth(end)) } else { (azimuth(end), azimuth(start)) };
693 let mut span = a1 - a0;
694 while span <= 0.0 {
695 span += std::f64::consts::TAU;
696 }
697 if (span - magnitude).abs() > std::f64::consts::PI {
698 magnitude
699 } else {
700 span
701 }
702 };
703 let mut curve = plane_ruled_section_arc(origin, normal, &frame, rho0, rho1, height, from, magnitude, tolerance)
704 .map_err(|error| {
705 KernelRefusal::unsupported(KernelStage::Refine, "draft_section", format!(
706 "{OP}: edge {} between faces {uses:?}: {error}", edge.id
707 ))
708 })?;
709 if sweep < 0.0 {
710 curve = curve.reversed().or_refuse(KernelStage::Sew, "reversed")?;
711 }
712 let [d0, d1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
713 let (c0, c1) = (
714 curve.evaluate(d0).or_refuse(KernelStage::Sew, "evaluate")?,
715 curve.evaluate(d1).or_refuse(KernelStage::Sew, "evaluate")?,
716 );
717 if c0.sub(start).length() > accept || c1.sub(end).length() > accept {
718 return Err(KernelRefusal::internal(KernelStage::Validate, "draft_section_ends", format!(
719 "{OP}: the re-intersected section of edge {} misses its corners by {:.3e} and \
720 {:.3e} — refusing",
721 edge.id,
722 c0.sub(start).length(),
723 c1.sub(end).length()
724 )));
725 }
726 edge.curve = curve;
727 edge.t0 = d0;
728 edge.t1 = d1;
729 }
730 for vertex in &mut result.vertices {
731 if let Some(point) = new_vertex.get(&vertex.id) {
732 vertex.point = *point;
733 }
734 }
735 let final_edges: HashMap<u64, EdgeRecord> = result.edges.iter().map(|edge| (edge.id, edge.clone())).collect();
736
737 let mut ruled_retrims = Vec::new();
739 for shell in &mut result.shells {
740 for face in &mut shell.faces {
741 let touched = face
742 .loops
743 .iter()
744 .flat_map(|l| l.coedges.iter())
745 .any(|c| changed_edges.contains(&c.edge_id));
746 match drafts.get(&face.id) {
747 Some(Draft::Plane { old, rotation, .. }) => {
748 let turned = Plane {
749 origin: rotation.point(old.origin),
750 u_dir: super::face_rotate::rotate_direction(rotation, old.u_dir),
751 v_dir: super::face_rotate::rotate_direction(rotation, old.v_dir),
752 normal: super::face_rotate::rotate_direction(rotation, old.normal),
753 };
754 retrim_planar_face(face, &turned, &final_edges, scale, OP)?;
755 }
756 Some(Draft::Cylinder { axis_point, radius, sign }) => {
757 let surface = drafted_cylinder(&face.surface, pull, *axis_point, *radius, *sign, tan, face.id)?;
758 let carrier = &carriers[&face.id];
759 let [u0, u1] = surface.domain_u().or_refuse(KernelStage::Refine, "domain_u")?;
760 let [v0, v1] = surface.domain_v().or_refuse(KernelStage::Refine, "domain_v")?;
761 for i in 0..=4 {
762 for j in 0..=4 {
763 let p = surface
764 .evaluate(u0 + (u1 - u0) * i as f64 / 4.0, v0 + (v1 - v0) * j as f64 / 4.0)
765 .or_refuse(KernelStage::Refine, "evaluate")?;
766 if carrier.residual(p).abs() > accept {
767 return Err(KernelRefusal::internal(KernelStage::Validate, "draft_cone_net", format!(
768 "{OP}: the drafted net of face {} leaves its cone by {:.3e} — refusing",
769 face.id,
770 carrier.residual(p).abs()
771 )));
772 }
773 }
774 }
775 rebuild_loop_pcurves(face, &final_edges, &surface, tolerance, OP)?;
776 face.surface = surface;
777 }
778 None if touched => match &carriers.get(&face.id) {
779 Some(Carrier::Plane { .. }) => {
780 let plane = plane_of_surface(&face.surface, plane_tolerance, OP)?;
781 retrim_planar_face(face, &plane, &final_edges, scale, OP)?;
782 }
783 Some(Carrier::Ruled { .. }) => ruled_retrims.push(face.id),
784 None => {
785 return Err(KernelRefusal::internal(KernelStage::Sew, "draft_unclassified", format!(
786 "{OP}: face {} lost an edge to the draft but has no carrier — refusing",
787 face.id
788 )));
789 }
790 },
791 None => {}
792 }
793 }
794 }
795 for face_id in ruled_retrims {
796 retrim_ruled_face(&mut result, face_id, &final_edges, tolerance, OP)?;
797 }
798 result.mass_properties_cache = Default::default();
799 accept_result(solid, &result)?;
800 Ok(result)
801}
802
803fn drafted_cylinder(
810 surface: &NurbsSurface,
811 pull: Vec3,
812 axis_point: Vec3,
813 radius: f64,
814 sign: f64,
815 tan: f64,
816 face_id: u64,
817) -> Result<NurbsSurface, KernelRefusal> {
818 let mut rows = surface.control_points.clone();
819 for row in &mut rows {
820 for control in row.iter_mut() {
821 let point = control.point().or_refuse(KernelStage::Refine, "point")?;
822 let w = point.sub(axis_point);
823 let h = w.dot(pull);
824 let q = w.sub(pull.scale(h));
825 let r = radius - sign * h * tan;
826 if r <= 0.0 {
827 return Err(KernelRefusal::unsupported(KernelStage::Refine, "draft_cone_apex", format!(
828 "{OP}: the cone drafted from face {face_id} reaches its apex within the face's \
829 carrier (radius {r:.6e} at height {h:.6e}) — refusing"
830 )));
831 }
832 let moved = axis_point.add(pull.scale(h)).add(q.scale(r / radius));
833 *control = Vec4::from_point(moved, control.w);
834 }
835 }
836 NurbsSurface::new(
837 surface.degree_u,
838 surface.degree_v,
839 surface.knots_u.clone(),
840 surface.knots_v.clone(),
841 rows,
842 )
843 .or_refuse(KernelStage::Refine, "drafted_cone")
844}
845
846fn accept_result(input: &BrepSolid, result: &BrepSolid) -> Result<(), KernelRefusal> {
848 let report = result.validate_detailed(&crate::KernelTolerances::for_solid(result, 1e-7));
849 if !report.issues.is_empty() {
850 return Err(KernelRefusal::internal(KernelStage::Validate, "validate", format!(
851 "{OP}: the drafted solid failed validation: {:?}", report.issues
852 )));
853 }
854 let crossings = crate::loop_self_crossings(result);
855 if !crossings.unreadable.is_empty() || crossings.loops == 0 {
856 return Err(KernelRefusal::internal(KernelStage::Validate, "crossing_scan_unreadable", format!(
857 "{OP}: the loop self-crossing scan could not read the drafted body ({:?}) — refusing",
858 crossings.unreadable
859 )));
860 }
861 if crossings.is_flagged() {
862 return Err(KernelRefusal::unsupported(KernelStage::Validate, CONSUMES_FACE, format!(
863 "{OP}: the draft makes a face's own loop cross itself ({}) — refusing",
864 crossings.summary().unwrap_or_default()
865 )));
866 }
867 if let (Ok(before), Ok(after)) =
868 (crate::signed_volume_against(input, 0.0), crate::signed_volume_against(result, 0.0))
869 {
870 if before * after <= 0.0 {
871 return Err(KernelRefusal::unsupported(KernelStage::Validate, CONSUMES_FACE, format!(
872 "{OP}: the draft inverts the solid (signed volume changed sign) — refusing"
873 )));
874 }
875 }
876 if !report.wire_warnings.is_empty() {
877 let inherited = input.validate_detailed(&crate::KernelTolerances::for_solid(input, 1e-7)).wire_warnings;
878 if report.wire_warnings.len() > inherited.len() {
879 return Err(KernelRefusal::internal(KernelStage::Validate, "chart_winding", format!(
880 "{OP}: the draft leaves a face whose (u,v) chart is left-handed against its own \
881 normal ({}) — refusing",
882 report.wire_warnings.iter().map(|w| w.message.as_str()).collect::<Vec<_>>().join("; ")
883 )));
884 }
885 }
886 Ok(())
887}
888
889fn draft_convex_polyhedron(
897 solid: &BrepSolid,
898 face_ids: &[u64],
899 pull_direction: Vec3,
900 neutral_point: Vec3,
901 angle_radians: f64,
902) -> Result<BrepSolid, String> {
903 let pull = pull_direction.normalized()?;
904 let scale = solid_model_scale(solid);
905 let plane_tolerance = (scale * 1e-6).max(1e-7);
906 let selected: HashSet<u64> = face_ids.iter().copied().collect();
907 let (sin, cos) = angle_radians.sin_cos();
908 let mut half_spaces: Vec<(Vec3, Vec3)> = Vec::new();
909 for shell in &solid.shells {
910 for face in &shell.faces {
911 let plane = plane_of_surface(&face.surface, plane_tolerance, OP)
912 .map_err(|_| format!("face {} is not planar", face.id))?;
913 let outward = plane.normal.scale(outward_sign(face));
914 if selected.contains(&face.id) {
915 let height = plane.origin.sub(neutral_point).dot(pull);
916 let on_hinge = plane.origin.sub(pull.scale(height));
917 half_spaces.push((on_hinge, outward.scale(cos).add(pull.scale(sin)).normalized()?));
918 } else {
919 half_spaces.push((plane.origin, outward));
920 }
921 }
922 }
923 if solid.shells.len() != 1 {
924 return Err("the body has more than one shell".into());
925 }
926 for shell in &solid.shells {
927 for face in &shell.faces {
928 let plane = plane_of_surface(&face.surface, plane_tolerance, OP).map_err(|e| e.to_string())?;
929 let outward = plane.normal.scale(outward_sign(face));
930 if solid.vertices.iter().any(|v| v.point.sub(plane.origin).dot(outward) > plane_tolerance * 10.0) {
931 return Err(format!("the body is not convex (a vertex lies outside face {})", face.id));
932 }
933 }
934 }
935 let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
936 let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
937 for v in &solid.vertices {
938 low = Vec3::new(low.x.min(v.point.x), low.y.min(v.point.y), low.z.min(v.point.z));
939 high = Vec3::new(high.x.max(v.point.x), high.y.max(v.point.y), high.z.max(v.point.z));
940 }
941 let extent = high.sub(low).length();
942 let margin = extent * (1.0 + angle_radians.tan().abs()) + 1.0;
943 let corner = low.sub(Vec3::new(margin, margin, margin));
944 let size = high.sub(low).add(Vec3::new(2.0 * margin, 2.0 * margin, 2.0 * margin));
945 let mut body = crate::make_box_brep(corner, size.x, size.y, size.z)?;
946 for (origin, normal) in &half_spaces {
947 match crate::split_solid_by_plane(&body, *origin, *normal) {
948 Ok((inside, _)) => body = inside,
949 Err(error) if error.contains("does not intersect") => {
950 if body.vertices.iter().all(|v| v.point.sub(*origin).dot(*normal) >= -plane_tolerance) {
951 return Err("the drafted half-spaces leave nothing".into());
952 }
953 }
954 Err(error) => return Err(error),
955 }
956 }
957 let inner_low = corner.add(Vec3::new(plane_tolerance, plane_tolerance, plane_tolerance).scale(1e3));
960 let inner_high = corner.add(size).sub(Vec3::new(plane_tolerance, plane_tolerance, plane_tolerance).scale(1e3));
961 if body.vertices.iter().any(|v| {
962 v.point.x <= inner_low.x || v.point.y <= inner_low.y || v.point.z <= inner_low.z
963 || v.point.x >= inner_high.x || v.point.y >= inner_high.y || v.point.z >= inner_high.z
964 }) {
965 return Err("the drafted half-spaces do not bound a solid".into());
966 }
967 accept_result(solid, &body).map_err(|e| e.to_string())?;
968 Ok(body)
969}
970
971pub fn draft_faces_parted(
979 solid: &BrepSolid,
980 face_ids: &[u64],
981 pull_direction: Vec3,
982 parting_point: Vec3,
983 angle_radians: f64,
984) -> Result<BrepSolid, KernelRefusal> {
985 let pull = check_inputs(face_ids, pull_direction, parting_point, angle_radians)?;
986 let scale = solid_model_scale(solid);
987 let plane_tolerance = (scale * 1e-6).max(1e-7);
988 let mut face_lookup: HashMap<u64, (usize, usize)> = HashMap::default();
989 for (shell_index, shell) in solid.shells.iter().enumerate() {
990 for (face_index, face) in shell.faces.iter().enumerate() {
991 face_lookup.entry(face.id).or_insert((shell_index, face_index));
992 }
993 }
994 let mut wanted = Vec::new();
995 for &face_id in face_ids {
996 if !face_lookup.contains_key(&face_id) {
997 return Err(KernelRefusal::input(KernelStage::Collect, "face_id", format!("{OP}: no face with id {face_id}")));
998 }
999 let carrier = carrier_of(solid, &face_lookup, face_id, pull, plane_tolerance).ok_or_else(|| {
1000 KernelRefusal::unsupported(KernelStage::Classify, "draft_carrier_kind", format!(
1001 "{OP}: face {face_id} is neither a plane parallel to the pull direction nor a \
1002 cylinder whose axis is the pull direction — refusing"
1003 ))
1004 })?;
1005 wanted.push(carrier);
1006 }
1007 let (below, above) = crate::split_solid_by_plane(solid, parting_point, pull).map_err(|error| {
1008 KernelRefusal::unsupported(KernelStage::Classify, "draft_parting_plane", format!(
1009 "{OP}: the parting plane does not divide the body ({error}) — refusing"
1010 ))
1011 })?;
1012 let same = |a: &Carrier, b: &Carrier| -> bool {
1013 match (a, b) {
1014 (Carrier::Plane { origin: oa, normal: na }, Carrier::Plane { origin: ob, normal: nb }) => {
1015 na.cross(*nb).length() <= 1e-9 && ob.sub(*oa).dot(*na).abs() <= plane_tolerance
1016 }
1017 (
1018 Carrier::Ruled { frame: fa, rho0: ra0, rho1: ra1, height: ha },
1019 Carrier::Ruled { frame: fb, rho0: rb0, rho1: rb1, height: hb },
1020 ) => {
1021 let offset = fb.origin.sub(fa.origin);
1022 fa.axis.cross(fb.axis).length() <= 1e-9
1023 && offset.sub(fa.axis.scale(offset.dot(fa.axis))).length() <= plane_tolerance
1024 && ((ra1 - ra0) / ha).abs() <= 1e-9
1025 && ((rb1 - rb0) / hb).abs() <= 1e-9
1026 && (ra0 - rb0).abs() <= plane_tolerance
1027 }
1028 _ => false,
1029 }
1030 };
1031 let mut halves = Vec::new();
1032 for (piece, side_pull) in [(above, pull), (below, pull.scale(-1.0))] {
1033 let mut lookup: HashMap<u64, (usize, usize)> = HashMap::default();
1034 for (shell_index, shell) in piece.shells.iter().enumerate() {
1035 for (face_index, face) in shell.faces.iter().enumerate() {
1036 lookup.entry(face.id).or_insert((shell_index, face_index));
1037 }
1038 }
1039 let mut ids: Vec<u64> = lookup
1040 .keys()
1041 .copied()
1042 .filter(|&id| {
1043 carrier_of(&piece, &lookup, id, pull, plane_tolerance)
1044 .map_or(false, |c| wanted.iter().any(|w| same(w, &c)))
1045 })
1046 .collect();
1047 ids.sort_unstable();
1048 if ids.is_empty() {
1049 return Err(KernelRefusal::unsupported(KernelStage::Classify, "draft_parting_side", format!(
1050 "{OP}: no selected face reaches one side of the parting plane — refusing"
1051 )));
1052 }
1053 halves.push(draft_faces(&piece, &ids, side_pull, parting_point, angle_radians)?);
1054 }
1055 crate::boolean_operation(
1056 &halves[0],
1057 &halves[1],
1058 crate::BooleanOperation::Union,
1059 &crate::BooleanOptions::default(),
1060 )
1061}