1use geop_core_geometry::contains::surface::surface_could_contain;
24use geop_core_geometry::intersection::curve_curve_overlaps_and_crossings;
25use geop_core_geometry::nurb_curve::{NurbCurve2D, flatten_point};
26use geop_core_geometry::search::{MAX_NODES, min_subdivision_size};
27use geop_core_math::{
28 geop_error::{GeopError, GeopResult, WithContext},
29 primitives::CoordinateSystem,
30 scalars::Scalar,
31 vector::{Vector2, Vector3},
32 with_context,
33};
34use geop_core_topology::{
35 EdgeId, FaceId,
36 build::{BodySpec, CoedgeOn, CoedgeSpec},
37 contains::face::{PointClassification, face_contains},
38};
39use geop_lib_booleans::{Reach, combine_tools};
40use geop_lib_sweep::{SweepLoop, extrude};
41use geop_ops_model::{Combine, Tool};
42use geop_ops_model::{NamedModel, Namer, frame_along};
43
44use crate::{
45 band::{Chain, Piece},
46 common::halfway,
47};
48
49pub fn rim_chain(
56 part: &NamedModel,
57 face: FaceId,
58 edges: &[EdgeId],
59) -> GeopResult<(CoordinateSystem, Chain)> {
60 let model = part.topology();
61 let name = |id: geop_ops_model::RefId| part.name_of(id).unwrap_or("?").to_string();
62 let solid = part.solid_of_face(face, "a rim is a face of a solid")?;
63 let plane = part.plane_of_face(face, "a lip or groove runs along a flat rim")?;
64 let faces = model.solid_faces(solid)?;
65 let (spec, sources) = model.body_spec(&faces, true)?;
66 let f = sources
67 .faces
68 .iter()
69 .position(|&x| x == face)
70 .expect("a face of its solid");
71 let frame = frame_along(plane.point, &plane.normal)?;
72 let loops = spec.faces[f].loops();
73 let edge_index = |e: EdgeId| sources.edges.iter().position(|&x| x == e);
74
75 let (lp, picked): (&Vec<CoedgeSpec>, Vec<bool>) = if edges.is_empty() {
77 match spec.faces[f].holes.as_slice() {
78 [hole] => (hole, vec![true; hole.len()]),
79 holes => {
80 return Err(GeopError::new(format!(
81 "face {} has {} holes: pick the edges to run along",
82 name(face.into()),
83 holes.len()
84 )));
85 }
86 }
87 } else {
88 let wanted: Vec<usize> = edges
89 .iter()
90 .map(|&e| {
91 edge_index(e).ok_or_else(|| {
92 GeopError::new(format!(
93 "edge {} is not an edge of face {}",
94 name(e.into()),
95 name(face.into())
96 ))
97 })
98 })
99 .collect::<GeopResult<_>>()?;
100 let on = |c: &CoedgeSpec| matches!(c.on, CoedgeOn::Edge(e, _) if wanted.contains(&e));
101 let lp = loops.iter().find(|lp| lp.iter().any(on)).ok_or_else(|| {
102 GeopError::new(format!(
103 "edge {} does not bound face {}",
104 name(edges[0].into()),
105 name(face.into())
106 ))
107 })?;
108 let picked: Vec<bool> = lp.iter().map(on).collect();
109 if picked.iter().filter(|&&p| p).count() != wanted.len() {
110 return Err(GeopError::new(format!(
111 "the edges picked do not all run along one loop of face {}",
112 name(face.into())
113 )));
114 }
115 (lp, picked)
116 };
117 let n = lp.len();
118 let closed = picked.iter().all(|&p| p);
119 let first = if closed {
121 0
122 } else {
123 (0..n)
124 .find(|&k| picked[k] && !picked[(k + n - 1) % n])
125 .expect("some picked, some not")
126 };
127 let run: Vec<usize> = (0..n)
128 .map(|i| (first + i) % n)
129 .take_while(|&k| picked[k])
130 .collect();
131 if run.len() != picked.iter().filter(|&&p| p).count() {
132 return Err(GeopError::new(format!(
133 "the edges picked along face {} are not one chain: they leave gaps",
134 name(face.into())
135 )));
136 }
137
138 let uses = spec.edge_uses();
140 let wall = |k: usize| -> GeopResult<FaceId> {
141 let CoedgeOn::Edge(e, _) = lp[k].on else {
142 return Err(GeopError::new(format!(
143 "face {} has a loop that is a single point",
144 name(face.into())
145 )));
146 };
147 let g = uses[e]
148 .iter()
149 .map(|&(g, ..)| g)
150 .find(|&g| g != f)
151 .ok_or_else(|| GeopError::new("the edge bounds the rim alone"))?;
152 Ok(sources.faces[g])
153 };
154 let smooth = |a: usize, b: usize| -> GeopResult<bool> {
160 let (vertex, _) = spec.ends(lp[b].on)?;
161 let vertex = sources.vertices[vertex];
162 let p = model.normal_at_vertex(wall(a)?, vertex)?;
163 let q = model.normal_at_vertex(wall(b)?, vertex)?;
164 Ok(p.could_be_parallel(&q) && p.prod_dot(&q).definitely_greater(Scalar::ZERO))
165 };
166 let to_plane = |p: &Vector3| frame.to_uv(p);
167 let mut smooths = Vec::with_capacity(run.len());
168 let mut pieces = Vec::with_capacity(run.len());
169 let mut joints = Vec::with_capacity(run.len() + 1);
170 for &k in &run {
171 let CoedgeOn::Edge(e, sense) = lp[k].on else {
172 return Err(GeopError::new(format!(
173 "face {} has a loop that is a single point",
174 name(face.into())
175 )));
176 };
177 let edge_name = name(sources.edges[e].into());
178 let curve = sense.orient(&spec.edges[e].curve);
179 let center = curve.as_arc()?.map(|arc| to_plane(&arc.circle.center));
180 convex_along(&spec, e, f, &curve, &plane.normal)
181 .map_err(|err| err.with_context(format!("edge {edge_name} of the rim")))?;
182 let flat = curve
184 .control_points
185 .iter()
186 .map(|cp| flatten_point(cp, frame.origin(), frame.u(), frame.v()))
187 .collect();
188 let mut curve2 = NurbCurve2D::try_new(curve.degree, flat, curve.knot_vector.clone())?;
189 if curve.as_line()?.is_some() {
190 curve2 = NurbCurve2D::line(curve2.start()?, curve2.end()?)?;
193 }
194 let (start, _) = spec.ends(lp[k].on)?;
195 joints.push(name(sources.vertices[start].into()));
196 smooths.push((closed || k != first) && smooth((k + n - 1) % n, k)?);
197 pieces.push(Piece {
198 curve: curve2,
199 center,
200 name: edge_name,
201 });
202 }
203 if !closed {
204 let (_, end) = spec.ends(lp[*run.last().unwrap()].on)?;
205 joints.push(name(sources.vertices[end].into()));
206 let last = *run.last().unwrap();
211 for (before, after, joint) in [
212 ((first + n - 1) % n, first, &joints[0]),
213 (last, (last + 1) % n, joints.last().unwrap()),
214 ] {
215 if !smooth(before, after)? {
216 return Err(GeopError::new(format!(
217 "the edges picked end at vertex {joint}, a corner of face {}: a lip or groove along part of a loop has to end where the loop runs on smoothly — pick the whole loop, or end the chain at a round",
218 name(face.into())
219 )));
220 }
221 }
222 }
223 let mut chain = Chain {
226 pieces,
227 joints,
228 smooth: smooths,
229 closed,
230 };
231 stitch(&mut chain)?;
232 Ok((frame, chain))
233}
234
235fn stitch(chain: &mut Chain) -> GeopResult<()> {
239 let n = chain.pieces.len();
240 let count = if chain.closed { n } else { n - 1 };
241 for i in 0..count {
242 let j = (i + 1) % n;
243 let end = *chain.pieces[i].curve.control_points.last().unwrap();
244 let start = chain.pieces[j].curve.control_points[0];
245 let (a, b) = (end.dehomogenized()?, start.dehomogenized()?);
246 if !a.could_be_equal(&b) {
247 return Err(GeopError::new(format!(
248 "{} does not end where {} starts",
249 chain.pieces[i].name, chain.pieces[j].name
250 )));
251 }
252 let at = Vector2::from_array([a[0].intersect(b[0]), a[1].intersect(b[1])]);
253 let set = |h: &mut Vector3| *h = at.homogeneous(h[2]);
254 set(chain.pieces[i].curve.control_points.last_mut().unwrap());
255 set(&mut chain.pieces[j].curve.control_points[0]);
256 }
257 Ok(())
258}
259
260fn convex_along(
265 spec: &BodySpec,
266 e: usize,
267 f: usize,
268 curve: &geop_core_topology::Curve3,
269 up: &Vector3,
270) -> GeopResult<()> {
271 let other = spec.edge_uses()[e]
272 .iter()
273 .map(|&(g, ..)| g)
274 .find(|&g| g != f)
275 .ok_or_else(|| GeopError::new("the edge bounds the rim alone"))?;
276 let (point, along) = halfway(curve)?;
277 let right = along.prod_cross(up);
278 let normal = spec.faces[other]
279 .surface
280 .normal_at(&point, MAX_NODES, min_subdivision_size())?;
281 if !normal.prod_dot(&right).definitely_greater(Scalar::ZERO) {
282 return Err(GeopError::new(
283 "the solid is not convex along it: a lip or groove runs along an edge where the wall falls away from the rim",
284 ));
285 }
286 Ok(())
287}
288
289fn fits_on_rim(
295 part: &NamedModel,
296 face: FaceId,
297 frame: &CoordinateSystem,
298 chain: &Chain,
299 far: Scalar,
300 what: &str,
301) -> GeopResult<()> {
302 let model = part.topology();
303 let name = |id: geop_ops_model::RefId| part.name_of(id).unwrap_or("?").to_string();
304 let refuse = |why: String| {
305 GeopError::new(format!(
306 "a {what} {:?} wide does not fit on the rim, face {}: {why} — make it narrower than the rim",
307 far.to_f64(),
308 name(face.into())
309 ))
310 };
311 let far_side = chain.offset(far)?;
312 for coedge in model.iterate_face_coedges(face) {
313 let edge = model.get_coedge(coedge)?.edge()?;
314 let curve = &model.get_edge(edge)?.curve;
315 let flat = curve
316 .control_points
317 .iter()
318 .map(|cp| flatten_point(cp, frame.origin(), frame.u(), frame.v()))
319 .collect();
320 let flat = NurbCurve2D::try_new(curve.degree, flat, curve.knot_vector.clone())?;
321 for side in &far_side {
322 let (overlaps, crossings) = curve_curve_overlaps_and_crossings(
323 side,
324 &flat,
325 Scalar::ZERO,
326 MAX_NODES,
327 min_subdivision_size(),
328 )?;
329 if !(overlaps.is_empty() && crossings.is_empty()) {
330 return Err(refuse(format!(
331 "its far side meets the rim's edge {}",
332 name(edge.into())
333 )));
334 }
335 }
336 }
337 let side = &far_side[0];
338 let at = side.evaluate(side.mid_parameter()?)?;
339 let point = frame
340 .origin()
341 .add(&frame.u().prod_scalar(at[0]))
342 .add(&frame.v().prod_scalar(at[1]));
343 let surface = &model.get_face(face)?.surface;
344 let inside = match surface_could_contain(surface, &point, MAX_NODES, min_subdivision_size())? {
345 Some((u, v)) => face_contains(
346 model,
347 face,
348 u,
349 v,
350 MAX_NODES,
351 min_subdivision_size(),
352 RIM_SEED,
353 )?,
354 None => PointClassification::Outside,
355 };
356 if inside != PointClassification::Inside {
357 return Err(refuse("its far side lies past the rim's other edge".into()));
358 }
359 Ok(())
360}
361
362const RIM_SEED: u64 = 0x5249_4D00_0000_0001;
364
365#[derive(Clone, Copy, Debug)]
367pub struct LipSize {
368 pub width: Scalar,
369 pub height: Scalar,
370}
371
372fn check_size(what: &str, value: Scalar) -> GeopResult<()> {
373 if !value.definitely_greater(Scalar::ZERO) {
374 return Err(GeopError::new(format!(
375 "a lip's {what} is more than zero, not {:?}",
376 value.to_f64()
377 )));
378 }
379 Ok(())
380}
381
382pub fn lip(
388 part: &mut NamedModel,
389 namer: &Namer,
390 operation_id: &str,
391 face: FaceId,
392 edges: &[EdgeId],
393 size: LipSize,
394) -> GeopResult<()> {
395 let ctx = with_context!(
396 "lip({}, face={face}, edges={edges:?}, {size:?})",
397 namer.root()
398 );
399 check_size("width", size.width).with_context(ctx)?;
400 check_size("height", size.height).with_context(ctx)?;
401 let target = part
402 .solid_name_of_face(face, "a rim is a face of a solid")
403 .with_context(ctx)?;
404 let (frame, chain) = rim_chain(part, face, edges).with_context(ctx)?;
405 if let Some(piece) = chain
411 .pieces
412 .iter()
413 .find(|p| !p.is_straight() && p.center.is_none())
414 {
415 return Err(GeopError::new(format!(
416 "edge {} is neither straight nor circular: a lip stands flush with the wall along it, and its face there would only approximate the wall's surface — a lip runs along straight and circular edges, a groove along any",
417 piece.name
418 )))
419 .with_context(ctx);
420 }
421 fits_on_rim(part, face, &frame, &chain, size.width, "lip").with_context(ctx)?;
422 let loops: Vec<SweepLoop> = chain
423 .band(Scalar::ZERO, size.width)
424 .with_context(ctx)?
425 .into_iter()
426 .map(SweepLoop::plain)
427 .collect();
428 let built = extrude(
429 part,
430 namer,
431 Some(&namer.name(&["tool"])),
432 &frame,
433 &[Scalar::ZERO, size.height],
434 &loops,
435 )
436 .with_context(ctx)?;
437 let tool = Tool::whole(built.solid.expect("extruded as a solid"));
438 let combine = Combine::join(target);
439 combine_tools(part, namer, operation_id, &[tool], &combine)
440 .and_then(Reach::stopped)
441 .with_context(ctx)
442}
443
444pub fn groove(
448 part: &mut NamedModel,
449 namer: &Namer,
450 operation_id: &str,
451 face: FaceId,
452 edges: &[EdgeId],
453 size: LipSize,
454 clearance: Scalar,
455) -> GeopResult<()> {
456 let ctx = with_context!(
457 "groove({}, face={face}, edges={edges:?}, {size:?}, clearance={clearance:?})",
458 namer.root()
459 );
460 check_size("width", size.width).with_context(ctx)?;
461 check_size("height", size.height).with_context(ctx)?;
462 if clearance.definitely_less(Scalar::ZERO) {
463 return Err(GeopError::new(format!(
464 "a groove's clearance is not less than zero, not {:?}",
465 clearance.to_f64()
466 )))
467 .with_context(ctx);
468 }
469 let target = part
470 .solid_name_of_face(face, "a rim is a face of a solid")
471 .with_context(ctx)?;
472 let (frame, chain) = rim_chain(part, face, edges).with_context(ctx)?;
473 fits_on_rim(
474 part,
475 face,
476 &frame,
477 &chain,
478 size.width.add(clearance),
479 "groove",
480 )
481 .with_context(ctx)?;
482 let air = size.width;
485 let loops: Vec<SweepLoop> = chain
486 .band(air.neg(), size.width.add(clearance))
487 .with_context(ctx)?
488 .into_iter()
489 .map(SweepLoop::plain)
490 .collect();
491 let built = extrude(
492 part,
493 namer,
494 Some(&namer.name(&["tool"])),
495 &frame,
496 &[air, size.height.add(clearance).neg()],
497 &loops,
498 )
499 .with_context(ctx)?;
500 let tool = Tool::whole(built.solid.expect("extruded as a solid"));
501 let combine = Combine::cut(target);
502 combine_tools(part, namer, operation_id, &[tool], &combine)
503 .and_then(Reach::stopped)
504 .with_context(ctx)
505}