1use ogeom_algo::{
58 Built, History, attach_pcurve, attach_seam, edge_vertices, make_edge_between, make_face_on,
59 make_face_with_pcurves, make_shell, make_solid, make_vertex, make_wire,
60};
61use ogeom_core::FastMap;
62use ogeom_core::{OgeomResult, Tolerance, Tolerances, ogeom_bail};
63use ogeom_geom::{
64 Curve, Curve2d as _, Curve3d as _, CylinderSurface, Line2d, LineCurve, OffsetSurface,
65 PlanarCurve, PlaneSurface, Surface as _, SurfaceGeometry, Transformable as _,
66};
67use ogeom_math::{
68 Axis2, Cylinder, Direction, Direction2, Frame, Point, Point2, Transform, Transform2, Vector,
69 Vector2,
70};
71use ogeom_topo::{
72 EdgeData, EdgeRepr, FaceData, Filter, Location, Model, NodeData, Orientation, Shape, ShapeType,
73 SurfaceId, TShapeId, explore,
74};
75
76pub fn offset_sheet(
98 model: &mut Model,
99 sheet: &Shape,
100 distance: f64,
101 tol: Tolerances,
102) -> OgeomResult<Built> {
103 if !distance.is_finite() || distance.abs() <= tol.confusion() {
104 ogeom_bail!(Construction, "an offset of {distance} moves nothing");
105 }
106 let read = read_sheet(model, sheet, tol)?;
107 let layer = moved_layer(model, &read, distance, None, tol)?;
108 let shape = if read.root_is_face {
109 layer.faces[0].clone()
110 } else {
111 make_shell(model, &layer.faces)?.shape
112 };
113 let mut history = layer.history;
114 if !read.root_is_face {
115 history.modify(&read.root, shape.clone());
116 }
117 if layer.faces.len() > 1 && !ogeom_algo::check_self_intersection(model, &shape, tol)?.is_empty()
118 {
119 ogeom_bail!(
120 Construction,
121 "the offset sheet runs into itself; the distance is larger than \
122 the room between its faces"
123 );
124 }
125 Ok(Built::new(shape, with_prefix(read.prefix, history)))
126}
127
128pub fn make_thick_sheet(
157 model: &mut Model,
158 sheet: &Shape,
159 thickness: f64,
160 both_sides: bool,
161 tol: Tolerances,
162) -> OgeomResult<Built> {
163 if !thickness.is_finite() || thickness.abs() <= tol.confusion() {
164 ogeom_bail!(Construction, "a thickness of {thickness} holds nothing");
165 }
166 let read = read_sheet(model, sheet, tol)?;
167 if read.faces.iter().any(|f| f.natural) {
168 ogeom_bail!(
169 Construction,
170 "a face with no boundary edges has nothing to close the sides \
171 along; bound it with edges first"
172 );
173 }
174 let free: Vec<usize> = (0..read.edges.len())
175 .filter(|&i| read.edges[i].uses.len() == 1 && !read.edges[i].data.degenerate)
176 .collect();
177 if free.is_empty() {
178 ogeom_bail!(
179 Construction,
180 "the sheet has no free edge; a closed shell is hollowed with \
181 make_thick_solid, not thickened"
182 );
183 }
184 let (lo, hi) = if both_sides {
185 (-thickness.abs() / 2.0, thickness.abs() / 2.0)
186 } else if thickness > 0.0 {
187 (0.0, thickness)
188 } else {
189 (thickness, 0.0)
190 };
191 let creases = creases(&read, lo.abs().max(hi.abs()), tol)?;
192 let lower = moved_layer(model, &read, lo, Some(&creases), tol)?;
193 let upper = moved_layer(model, &read, hi, Some(&creases), tol)?;
194
195 let mut history = History::new();
196 let mut faces: Vec<Shape> = Vec::new();
197 for (fi, face) in read.faces.iter().enumerate() {
198 faces.push(upper.faces[fi].clone());
201 faces.push(lower.faces[fi].reversed());
202 history.generate(&face.occurrence, upper.faces[fi].clone());
203 history.generate(&face.occurrence, lower.faces[fi].reversed());
204 }
205 let mut risers: FastMap<TShapeId, Shape> = FastMap::default();
206 for ei in free {
207 let side = if creases.touches(&read.edges[ei]) {
208 flat_side_face(model, &read, ei, (&lower, &upper), &mut risers, tol)?
209 } else {
210 side_face(
211 model,
212 &read,
213 ei,
214 (lo, hi),
215 (&lower, &upper),
216 &mut risers,
217 tol,
218 )?
219 };
220 history.generate(&Shape::of(read.edges[ei].node), side.clone());
221 faces.push(side);
222 }
223 let shell = make_shell(model, &faces)?.shape;
224 if !ogeom_algo::is_shell_closed(model, &shell)? {
225 ogeom_bail!(
226 Construction,
227 "the thickened sheet does not close: an edge bounds the sheet \
228 more than once in a way the side faces cannot follow"
229 );
230 }
231 let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
232 if !ogeom_algo::check_self_intersection(model, &solid, tol)?.is_empty() {
236 ogeom_bail!(
237 Construction,
238 "the thickened sheet runs into itself; the thickness is larger \
239 than the room between its faces"
240 );
241 }
242 if !ogeom_algo::inside_out_faces(model, &solid, tol)?.is_empty() {
243 ogeom_bail!(
244 Construction,
245 "the thickened sheet folds through itself and turns faces inside out"
246 );
247 }
248 if !read.root_is_face {
249 history.generate(&read.root, solid.clone());
250 }
251 Ok(Built::new(solid, with_prefix(read.prefix, history)))
252}
253
254fn with_prefix(prefix: Option<History>, history: History) -> History {
255 match prefix {
256 Some(prefix) => prefix.then(&history),
257 None => history,
258 }
259}
260
261struct SheetFace {
263 occurrence: Shape,
264 surface_id: SurfaceId,
265 surface: SurfaceGeometry,
266 sign: f64,
268 natural: bool,
269 tolerance: f64,
271 wires: Vec<(Orientation, Vec<(TShapeId, Orientation)>)>,
273 window: ((f64, f64), (f64, f64)),
275}
276
277struct EdgeUse {
279 face: usize,
280 sense: Orientation,
282 pcurve: Option<(PlanarCurve, (f64, f64))>,
285}
286
287struct SheetEdge {
289 node: TShapeId,
290 data: EdgeData,
291 curve: Option<(Curve, (f64, f64))>,
293 ends: (TShapeId, TShapeId),
295 uses: Vec<EdgeUse>,
296}
297
298struct Sheet {
300 root: Shape,
301 root_is_face: bool,
302 faces: Vec<SheetFace>,
303 edges: Vec<SheetEdge>,
304 vertices: Vec<SheetVertex>,
306 prefix: Option<History>,
307}
308
309type SheetVertex = (TShapeId, Point, Seats);
312
313type Seats = Vec<(usize, Point2)>;
315
316fn read_sheet(model: &mut Model, sheet: &Shape, tol: Tolerances) -> OgeomResult<Sheet> {
318 let kind = model.kind_of(sheet)?;
319 match kind {
320 ShapeType::Face | ShapeType::Shell => {}
321 ShapeType::Solid | ShapeType::CompSolid => ogeom_bail!(
322 Construction,
323 "a solid is offset with offset_shape and hollowed with \
324 make_thick_solid; a sheet is a face or a shell"
325 ),
326 other => ogeom_bail!(
327 Construction,
328 "a sheet is a face or a shell, not a {other:?}"
329 ),
330 }
331 let (root, prefix) = if placed(model, sheet)? {
332 baked_sheet(model, sheet, kind, tol)?
333 } else {
334 (sheet.clone(), None)
335 };
336
337 let mut faces: Vec<SheetFace> = Vec::new();
338 let mut seen: Vec<TShapeId> = Vec::new();
339 for occurrence in explore(model, &root, Filter::OfType(ShapeType::Face))? {
340 if seen.contains(&occurrence.node()) {
341 ogeom_bail!(Construction, "the sheet holds one face twice");
342 }
343 seen.push(occurrence.node());
344 let Some(node) = model.node(&occurrence) else {
345 ogeom_bail!(Dangling, "face is not in this model");
346 };
347 let NodeData::Face(data) = node.data() else {
348 ogeom_bail!(Construction, "face node holds no face data");
349 };
350 let Some(surface) = model.geometry().surface(data.surface).cloned() else {
351 ogeom_bail!(Dangling, "face refers to a surface not in this model");
352 };
353 let mut wires = Vec::new();
354 for wire in node.children() {
355 let Some(wire_node) = model.node(wire) else {
356 ogeom_bail!(Dangling, "wire is not in this model");
357 };
358 let edges = wire_node
359 .children()
360 .iter()
361 .map(|e| (e.node(), e.orientation()))
362 .collect();
363 wires.push((wire.orientation(), edges));
364 }
365 faces.push(SheetFace {
366 occurrence: occurrence.clone(),
367 surface_id: data.surface,
368 sign: if occurrence.orientation() == Orientation::Reversed {
369 -1.0
370 } else {
371 1.0
372 },
373 natural: data.natural_restriction || wires.is_empty(),
374 tolerance: data.tolerance.get(),
375 surface,
376 wires,
377 window: ((0.0, 0.0), (0.0, 0.0)),
378 });
379 }
380 if faces.is_empty() {
381 ogeom_bail!(Construction, "the sheet has no faces");
382 }
383
384 let mut edges: Vec<SheetEdge> = Vec::new();
385 let mut edge_index: FastMap<TShapeId, usize> = FastMap::default();
386 for (fi, face) in faces.iter().enumerate() {
387 for (wire_sense, list) in &face.wires {
388 for (edge_node, edge_sense) in list {
389 let sense = wire_sense.compose(*edge_sense);
390 let index = if let Some(i) = edge_index.get(edge_node) {
391 *i
392 } else {
393 let bare = Shape::of(*edge_node);
394 let Some(data) = model.node(&bare).and_then(|n| n.data().as_edge()).cloned()
395 else {
396 ogeom_bail!(Construction, "edge node holds no edge data");
397 };
398 let curve = match data.curve3d() {
399 Some(EdgeRepr::Curve3d { curve, range, .. }) => {
400 let Some(geometry) = model.geometry().curve(*curve) else {
401 ogeom_bail!(Dangling, "curve is not in this model");
402 };
403 Some((geometry.clone(), *range))
404 }
405 _ if data.degenerate => None,
406 _ => ogeom_bail!(Construction, "a sheet edge has no curve in space"),
407 };
408 let Some((start, end)) = edge_vertices(model, &bare)? else {
409 ogeom_bail!(Construction, "a sheet edge has no vertices");
410 };
411 edges.push(SheetEdge {
412 node: *edge_node,
413 data,
414 curve,
415 ends: (start.node(), end.node()),
416 uses: Vec::new(),
417 });
418 edge_index.insert(*edge_node, edges.len() - 1);
419 edges.len() - 1
420 };
421 let edge = &mut edges[index];
422 let pcurve = match edge.data.pcurve_on(face.surface_id) {
423 Some(EdgeRepr::PCurve { curve, range, .. }) => model
424 .geometry()
425 .pcurve(*curve)
426 .cloned()
427 .map(|p| (p, *range)),
428 Some(EdgeRepr::Seam {
429 forward,
430 reversed,
431 range,
432 ..
433 }) => {
434 let id = if sense == Orientation::Reversed {
435 *reversed
436 } else {
437 *forward
438 };
439 model.geometry().pcurve(id).cloned().map(|p| (p, *range))
440 }
441 _ => None,
442 };
443 if pcurve.is_none() && edge.data.degenerate {
444 ogeom_bail!(Construction, "a pole edge has no pcurve to place it");
445 }
446 edge.uses.push(EdgeUse {
447 face: fi,
448 sense,
449 pcurve,
450 });
451 }
452 }
453 }
454 for edge in &edges {
455 let faces_met: ogeom_core::FastSet<usize> = edge.uses.iter().map(|u| u.face).collect();
456 if edge.uses.len() > 2 {
457 ogeom_bail!(
458 Construction,
459 "an edge is shared by more than two faces; the sheet is not a \
460 surface the offset can move"
461 );
462 }
463 if faces_met.len() == 1 && edge.uses.len() == 2 && edge.uses[0].sense == edge.uses[1].sense
464 {
465 ogeom_bail!(Construction, "a seam is walked the same way twice");
466 }
467 }
468
469 let mut vertices: Vec<SheetVertex> = Vec::new();
471 let mut vertex_index: FastMap<TShapeId, usize> = FastMap::default();
472 let mut seat = |model: &Model,
473 vertex: TShapeId,
474 face: usize,
475 uv: Option<Point2>,
476 surface: &SurfaceGeometry|
477 -> OgeomResult<()> {
478 let index = if let Some(i) = vertex_index.get(&vertex) {
479 *i
480 } else {
481 let Some(data) = model
482 .node(&Shape::of(vertex))
483 .and_then(|n| n.data().as_vertex())
484 else {
485 ogeom_bail!(Construction, "vertex node holds no point");
486 };
487 vertices.push((vertex, data.point, Vec::new()));
488 vertex_index.insert(vertex, vertices.len() - 1);
489 vertices.len() - 1
490 };
491 let uv = match uv {
492 Some(uv) => uv,
493 None => {
494 let found = ogeom_algo::project_on_surface(surface, vertices[index].1, 32, tol)?;
495 Point2::new(found.parameters.0, found.parameters.1)
496 }
497 };
498 vertices[index].2.push((face, uv));
499 Ok(())
500 };
501 let mut boxes: Vec<Option<(Point2, Point2)>> = vec![None; faces.len()];
502 let grow = |boxes: &mut Vec<Option<(Point2, Point2)>>, face: usize, uv: Point2| {
503 boxes[face] = Some(match boxes[face] {
504 None => (uv, uv),
505 Some((lo, hi)) => (
506 Point2::new(lo.x.min(uv.x), lo.y.min(uv.y)),
507 Point2::new(hi.x.max(uv.x), hi.y.max(uv.y)),
508 ),
509 });
510 };
511 for edge in &edges {
512 for used in &edge.uses {
513 let surface = &faces[used.face].surface;
514 let (start_uv, end_uv) = if let Some((p, range)) = &used.pcurve {
515 for k in 0..=24 {
516 let t = range.0 + (range.1 - range.0) * f64::from(k) / 24.0;
517 grow(&mut boxes, used.face, p.point_at(t, tol)?);
518 }
519 (
520 Some(p.point_at(range.0, tol)?),
521 Some(p.point_at(range.1, tol)?),
522 )
523 } else if let Some((curve, range)) = &edge.curve {
524 for k in 0..=24 {
525 let t = range.0 + (range.1 - range.0) * f64::from(k) / 24.0;
526 let found =
527 ogeom_algo::project_on_surface(surface, curve.point_at(t, tol)?, 32, tol)?;
528 grow(
529 &mut boxes,
530 used.face,
531 Point2::new(found.parameters.0, found.parameters.1),
532 );
533 }
534 (None, None)
535 } else {
536 (None, None)
537 };
538 seat(model, edge.ends.0, used.face, start_uv, surface)?;
539 seat(model, edge.ends.1, used.face, end_uv, surface)?;
540 }
541 }
542 for (fi, face) in faces.iter_mut().enumerate() {
543 let ((ua, ub), (va, vb)) = face.surface.domain();
544 face.window = match boxes[fi] {
545 Some((lo, hi)) if !face.natural => {
546 let mu = (hi.x - lo.x).max(tol.parametric()) * 0.02;
550 let mv = (hi.y - lo.y).max(tol.parametric()) * 0.02;
551 let pole =
555 |u: Option<f64>, v: Option<f64>| collapsed(&face.surface, u, v, (lo, hi), tol);
556 let margin = |m: f64, side: bool| if side { 0.0 } else { m };
557 let (mut u0, mut u1, mut v0, mut v1) = (
558 lo.x - margin(mu, pole(Some(lo.x), None)?),
559 hi.x + margin(mu, pole(Some(hi.x), None)?),
560 lo.y - margin(mv, pole(None, Some(lo.y))?),
561 hi.y + margin(mv, pole(None, Some(hi.y))?),
562 );
563 if !face.surface.is_periodic_u() {
564 u0 = u0.max(ua);
565 u1 = u1.min(ub);
566 }
567 if !face.surface.is_periodic_v() {
568 v0 = v0.max(va);
569 v1 = v1.min(vb);
570 }
571 ((u0, u1), (v0, v1))
572 }
573 _ => ((ua, ub), (va, vb)),
574 };
575 let ((u0, u1), (v0, v1)) = face.window;
576 if ![u0, u1, v0, v1].iter().all(|x| x.is_finite()) || u1 <= u0 || v1 <= v0 {
577 ogeom_bail!(
578 Construction,
579 "a face covers no finite region of its surface's chart"
580 );
581 }
582 }
583 Ok(Sheet {
584 root_is_face: kind == ShapeType::Face,
585 root,
586 faces,
587 edges,
588 vertices,
589 prefix,
590 })
591}
592
593fn placed(model: &Model, sheet: &Shape) -> OgeomResult<bool> {
596 for kind in [ShapeType::Vertex, ShapeType::Edge, ShapeType::Face] {
597 for occurrence in explore(model, sheet, Filter::OfType(kind))? {
598 if occurrence.transform(model.datums())?.kind() != ogeom_math::TransformKind::Identity {
599 return Ok(true);
600 }
601 let Some(node) = model.node(&occurrence) else {
602 ogeom_bail!(Dangling, "shape is not in this model");
603 };
604 match node.data() {
605 NodeData::Face(data) if !data.location.is_identity() => return Ok(true),
606 NodeData::Edge(data)
607 if data
608 .representations
609 .iter()
610 .any(|r| r.location().is_some_and(|l| !l.is_identity())) =>
611 {
612 return Ok(true);
613 }
614 _ => {}
615 }
616 }
617 }
618 Ok(false)
619}
620
621fn baked_sheet(
624 model: &mut Model,
625 sheet: &Shape,
626 kind: ShapeType,
627 tol: Tolerances,
628) -> OgeomResult<(Shape, Option<History>)> {
629 let shell = if kind == ShapeType::Face {
630 model.add_shell(std::slice::from_ref(sheet))?
631 } else {
632 sheet.clone()
633 };
634 let holder = model.add_solid(std::slice::from_ref(&shell))?;
635 let baked = ogeom_algo::baked_shape(model, &holder, tol)?;
636 let root = if kind == ShapeType::Face {
637 let faces = explore(model, &baked.shape, Filter::OfType(ShapeType::Face))?;
638 let [face] = faces.as_slice() else {
639 ogeom_bail!(Construction, "a placed face did not restate as one face");
640 };
641 face.clone()
642 } else {
643 let shells = explore(model, &baked.shape, Filter::OfType(ShapeType::Shell))?;
644 let [one] = shells.as_slice() else {
645 ogeom_bail!(Construction, "a placed shell did not restate as one shell");
646 };
647 one.clone()
648 };
649 let mut history = baked.history;
650 if history.modified(sheet).is_empty() {
651 history.modify(sheet, root.clone());
652 }
653 Ok((root, Some(history)))
654}
655
656struct MovedSurface {
658 id: SurfaceId,
659 geometry: SurfaceGeometry,
660 exact: bool,
662 deviation: f64,
664}
665
666struct Layer {
668 faces: Vec<Shape>,
670 edges: FastMap<TShapeId, Shape>,
672 vertices: FastMap<TShapeId, (Shape, Point)>,
674 history: History,
675}
676
677fn moved_layer(
686 model: &mut Model,
687 sheet: &Sheet,
688 distance: f64,
689 creases: Option<&Creases>,
690 tol: Tolerances,
691) -> OgeomResult<Layer> {
692 let creases = creases.filter(|c| distance != 0.0 && !c.edges.is_empty());
693 let target = tol.approximation();
694 let mut history = History::new();
695
696 let mut surfaces: Vec<MovedSurface> = Vec::with_capacity(sheet.faces.len());
697 for face in &sheet.faces {
698 let (geometry, exact, deviation) = if distance == 0.0 {
699 (face.surface.clone(), true, 0.0)
700 } else {
701 let bound = fit_bound(face.tolerance, distance, tol);
702 moved_surface(face, distance, (target, bound), tol)?
703 };
704 let id = model.geometry_mut().add_surface(geometry.clone());
705 surfaces.push(MovedSurface {
706 id,
707 geometry,
708 exact,
709 deviation,
710 });
711 }
712
713 let mut vertices: FastMap<TShapeId, (Shape, Point)> = FastMap::default();
715 let mut vertex_slop: FastMap<TShapeId, f64> = FastMap::default();
716 for (node, point, seats) in &sheet.vertices {
717 if let Some(&(ci, at_end)) = creases.and_then(|c| c.ends.get(node)) {
718 let edge = &sheet.edges[ci];
719 let Some((_, range)) = &edge.curve else {
720 ogeom_bail!(Construction, "a crease has no curve in space");
721 };
722 let t = if at_end { range.1 } else { range.0 };
723 let moved = mitre_at(sheet, &surfaces, edge, t, tol)?;
724 let shape = make_vertex(model, moved).shape;
725 history.modify(&Shape::of(*node), shape.clone());
726 vertex_slop.insert(*node, 0.0);
727 vertices.insert(*node, (shape, moved));
728 continue;
729 }
730 let mut candidates = Vec::with_capacity(seats.len());
731 for (fi, uv) in seats {
732 candidates.push(moved_point(
733 sheet, &surfaces, *fi, *uv, *point, distance, tol,
734 )?);
735 }
736 let (moved, spread) = agreed(&candidates, target, "a vertex")?;
737 let shape = make_vertex(model, moved).shape;
738 history.modify(&Shape::of(*node), shape.clone());
739 vertex_slop.insert(*node, spread);
740 vertices.insert(*node, (shape, moved));
741 }
742
743 let mut edges: FastMap<TShapeId, Shape> = FastMap::default();
745 for (ei, edge) in sheet.edges.iter().enumerate() {
746 let is_crease = creases.is_some_and(|c| c.edges.contains(&ei));
747 let cut = creases.map_or([false, false], |c| {
748 [
749 c.ends.contains_key(&edge.ends.0),
750 c.ends.contains_key(&edge.ends.1),
751 ]
752 });
753 let mut recharted: Option<(Curve, (f64, f64))> = None;
754 let (Some((from, from_at)), Some((to, to_at))) = (
755 vertices.get(&edge.ends.0).cloned(),
756 vertices.get(&edge.ends.1).cloned(),
757 ) else {
758 ogeom_bail!(Construction, "an edge end has no moved vertex");
759 };
760 let built = if let Some((curve, range)) = &edge.curve {
761 let off = |t: f64| -> OgeomResult<Point> {
762 let at = curve.point_at(t, tol)?;
763 let mut candidates = Vec::with_capacity(edge.uses.len());
764 for used in &edge.uses {
765 let uv = seat_on(sheet, edge, used, (at, t, *range), tol)?;
766 candidates.push(moved_point(
767 sheet, &surfaces, used.face, uv, at, distance, tol,
768 )?);
769 }
770 Ok(agreed(&candidates, target, "an edge")?.0)
771 };
772 let mitre = |t: f64| mitre_at(sheet, &surfaces, edge, t, tol);
773 let off: &dyn Fn(f64) -> OgeomResult<Point> = if is_crease { &mitre } else { &off };
774 let (moved, slop) = if distance == 0.0 {
775 (curve.clone(), 0.0)
776 } else if let Some(exact) = exact_moved_curve(curve, *range, off, tol)? {
777 (exact, 0.0)
778 } else {
779 let bound = edge
780 .uses
781 .iter()
782 .map(|u| fit_bound(sheet.faces[u.face].tolerance, distance, tol))
783 .fold(target, f64::max);
784 fitted_moved_curve(*range, off, (target, bound), tol)?
785 };
786 let (moved, range) = if !is_crease && cut.contains(&true) {
788 let ends = [cut[0].then_some(from_at), cut[1].then_some(to_at)];
789 retrimmed(&moved, *range, ends, tol)?
790 } else {
791 (moved, *range)
792 };
793 if is_crease || cut.contains(&true) {
794 recharted = Some((moved.clone(), range));
795 }
796 for (t, (vertex, at)) in [(range.0, (&from, from_at)), (range.1, (&to, to_at))] {
799 let gap = moved.point_at(t, tol)?.distance(at);
800 if gap > tol.confusion() {
801 model.widen(vertex, Tolerance::new(gap + tol.confusion())?)?;
802 }
803 }
804 let shape = make_edge_between(model, moved, range, &from, &to, tol)?.shape;
805 if slop > 0.0 {
806 model.widen(&shape, Tolerance::new(slop + tol.confusion())?)?;
807 }
808 shape
809 } else {
810 let mut data = EdgeData::new();
811 data.degenerate = true;
812 model.add_edge(data, &[from.clone(), to.clone()])?
813 };
814 for end in [edge.ends.0, edge.ends.1] {
815 let slop = vertex_slop.get(&end).copied().unwrap_or(0.0);
816 if slop > tol.confusion() {
817 model.widen(&vertices[&end].0, Tolerance::new(slop + tol.confusion())?)?;
818 }
819 }
820 if let Some((curve, range)) = &recharted {
822 for used in &edge.uses {
823 let surface = &surfaces[used.face].geometry;
824 let Some(image) = ogeom_intersect::exact_pcurve_over(curve, *range, surface, tol)
825 else {
826 ogeom_bail!(
827 Construction,
828 "the sheet's faces meet at a crease whose mitre has no exact \
829 image on a moved face; a crease is joined between planes, \
830 cylinders, cones, spheres and tori bounded by lines and circles"
831 );
832 };
833 let near = sheet
834 .vertices
835 .iter()
836 .find(|(node, _, _)| *node == edge.ends.0)
837 .and_then(|(_, _, seats)| seats.iter().find(|(f, _)| *f == used.face))
838 .map(|(_, uv)| *uv);
839 let image = match near {
840 Some(near) => on_branch(image, range.0, near, surface, tol)?,
841 None => image,
842 };
843 let sid = surfaces[used.face].id;
844 attach_pcurve(model, &built, image, sid, Location::identity(), *range)?;
845 }
846 model.widen(&built, edge.data.tolerance)?;
847 same_parameter(model, &built, tol)?;
848 history.modify(&Shape::of(edge.node), built.clone());
849 edges.insert(edge.node, built);
850 continue;
851 }
852 let mut done: Vec<SurfaceId> = Vec::new();
854 for used in &edge.uses {
855 let sid = surfaces[used.face].id;
856 if done.contains(&sid) {
857 continue;
858 }
859 done.push(sid);
860 let old = sheet.faces[used.face].surface_id;
861 match edge.data.pcurve_on(old) {
862 Some(EdgeRepr::PCurve { curve, range, .. }) => {
863 let Some(p) = model.geometry().pcurve(*curve).cloned() else {
864 ogeom_bail!(Dangling, "pcurve is not in this model");
865 };
866 attach_pcurve(model, &built, p, sid, Location::identity(), *range)?;
867 }
868 Some(EdgeRepr::Seam {
869 forward,
870 reversed,
871 range,
872 ..
873 }) => {
874 let (Some(f), Some(r)) = (
875 model.geometry().pcurve(*forward).cloned(),
876 model.geometry().pcurve(*reversed).cloned(),
877 ) else {
878 ogeom_bail!(Dangling, "pcurve is not in this model");
879 };
880 attach_seam(model, &built, f, r, sid, Location::identity(), *range)?;
881 }
882 _ => {}
883 }
884 }
885 model.widen(&built, edge.data.tolerance)?;
887 same_parameter(model, &built, tol)?;
888 history.modify(&Shape::of(edge.node), built.clone());
889 edges.insert(edge.node, built);
890 }
891
892 let mut faces: Vec<Shape> = Vec::with_capacity(sheet.faces.len());
894 for (fi, face) in sheet.faces.iter().enumerate() {
895 let mut wires = Vec::with_capacity(face.wires.len());
896 for (wire_sense, list) in &face.wires {
897 let ring: Vec<Shape> = list
898 .iter()
899 .map(|(node, sense)| edges[node].oriented(*sense))
900 .collect();
901 wires.push(model.add_wire(&ring)?.oriented(*wire_sense));
902 }
903 let data = if face.natural {
904 FaceData::natural(surfaces[fi].id, Location::identity())
905 } else {
906 FaceData::new(surfaces[fi].id, Location::identity())
907 };
908 let bare = model.add_face(data, &wires)?;
909 if surfaces[fi].deviation > 0.0 {
910 model.widen(
911 &bare,
912 Tolerance::new(surfaces[fi].deviation + tol.confusion())?,
913 )?;
914 }
915 let shape = bare.oriented(face.occurrence.orientation());
916 history.modify(&face.occurrence, shape.clone());
917 faces.push(shape);
918 }
919 Ok(Layer {
920 faces,
921 edges,
922 vertices,
923 history,
924 })
925}
926
927fn seat_on(
932 sheet: &Sheet,
933 edge: &SheetEdge,
934 used: &EdgeUse,
935 (at, t, range): (Point, f64, (f64, f64)),
936 tol: Tolerances,
937) -> OgeomResult<Point2> {
938 let surface = &sheet.faces[used.face].surface;
939 if let Some((p, own)) = &used.pcurve {
940 let s = if range == *own {
941 t
942 } else {
943 own.0 + (own.1 - own.0) * (t - range.0) / (range.1 - range.0)
944 };
945 let uv = p.point_at(s, tol)?;
946 let reach = edge.data.tolerance.get().max(tol.confusion()) * 10.0;
947 if surface.point_at(uv.x, uv.y, tol)?.distance(at) <= reach {
948 return Ok(uv);
949 }
950 }
951 let found = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
952 Ok(Point2::new(found.parameters.0, found.parameters.1))
953}
954
955fn moved_point(
960 sheet: &Sheet,
961 surfaces: &[MovedSurface],
962 face: usize,
963 uv: Point2,
964 at: Point,
965 distance: f64,
966 tol: Tolerances,
967) -> OgeomResult<Point> {
968 if distance == 0.0 {
969 return Ok(at);
970 }
971 if let Some(n) = sheet_normal(&sheet.faces[face], uv, tol) {
972 let moved = at + n * distance;
973 if surfaces[face].exact {
978 let exact = surfaces[face].geometry.point_at(uv.x, uv.y, tol)?;
979 if exact.distance(moved) > distance.abs() {
980 return Ok(exact);
981 }
982 }
983 return Ok(moved);
984 }
985 if surfaces[face].exact {
986 return surfaces[face].geometry.point_at(uv.x, uv.y, tol);
987 }
988 ogeom_bail!(
989 Construction,
990 "a free-form face has no normal at ({}, {}); its offset there is \
991 not determined",
992 uv.x,
993 uv.y
994 )
995}
996
997fn sheet_normal(face: &SheetFace, uv: Point2, tol: Tolerances) -> Option<Vector> {
1000 face.surface
1001 .normal_at(uv.x, uv.y, tol)
1002 .ok()
1003 .map(|n| n.vector() * face.sign)
1004}
1005
1006fn agreed(candidates: &[Point], target: f64, what: &str) -> OgeomResult<(Point, f64)> {
1010 let Some(first) = candidates.first() else {
1011 ogeom_bail!(Construction, "{what} bounds no face of the sheet");
1012 };
1013 #[allow(clippy::cast_precision_loss, reason = "a handful of faces")]
1014 let count = candidates.len() as f64;
1015 let sum = candidates
1016 .iter()
1017 .fold(Vector::ZERO, |acc, p| acc + (*p - *first));
1018 let mean = *first + sum / count;
1019 let spread = candidates
1020 .iter()
1021 .fold(0.0_f64, |acc, p| acc.max(p.distance(mean)));
1022 if spread > target {
1023 ogeom_bail!(
1024 Construction,
1025 "the sheet's faces meet at a crease: {what} they share moves \
1026 {} apart on the two sides, which the offset cannot join",
1027 2.0 * spread
1028 );
1029 }
1030 Ok((mean, spread))
1031}
1032
1033fn collapsed(
1037 surface: &SurfaceGeometry,
1038 u: Option<f64>,
1039 v: Option<f64>,
1040 (lo, hi): (Point2, Point2),
1041 tol: Tolerances,
1042) -> OgeomResult<bool> {
1043 let mut points = Vec::with_capacity(9);
1044 for k in 0..=8 {
1045 let t = f64::from(k) / 8.0;
1046 points.push(surface.point_at(
1047 u.unwrap_or(lo.x + (hi.x - lo.x) * t),
1048 v.unwrap_or(lo.y + (hi.y - lo.y) * t),
1049 tol,
1050 )?);
1051 }
1052 let first = points[0];
1053 let sum = points
1054 .iter()
1055 .fold(Vector::ZERO, |acc, p| acc + (*p - first));
1056 let centre = first + sum / 9.0;
1057 Ok(points.iter().all(|p| p.distance(centre) <= tol.confusion()))
1058}
1059
1060const PROBES: i32 = 8;
1062
1063const FIT_SHARE: f64 = 1e-4;
1066
1067fn fit_bound(face_tolerance: f64, distance: f64, tol: Tolerances) -> f64 {
1071 tol.approximation()
1072 .max(face_tolerance)
1073 .max(FIT_SHARE * distance.abs())
1074}
1075
1076fn moved_surface(
1081 face: &SheetFace,
1082 distance: f64,
1083 (target, bound): (f64, f64),
1084 tol: Tolerances,
1085) -> OgeomResult<(SurfaceGeometry, bool, f64)> {
1086 let along = face.sign * distance;
1087 let (base, carried) = match &face.surface {
1089 SurfaceGeometry::Offset(o) => (o.basis(), o.distance()),
1090 other => (other, 0.0),
1091 };
1092 let total = carried + along;
1093 let ((u0, u1), (v0, v1)) = face.window;
1094 let probe = |i: i32, j: i32| {
1095 (
1096 u0 + (u1 - u0) * f64::from(i) / f64::from(PROBES),
1097 v0 + (v1 - v0) * f64::from(j) / f64::from(PROBES),
1098 )
1099 };
1100
1101 for i in 0..=PROBES {
1104 for j in 0..=PROBES {
1105 let (u, v) = probe(i, j);
1106 let Some(curvatures) = principal_curvatures(base, u, v, tol)? else {
1107 continue;
1108 };
1109 for k in curvatures {
1110 if total * k >= 1.0 - 1e-9 {
1111 ogeom_bail!(
1112 Construction,
1113 "an offset of {distance} reaches past the face's \
1114 radius of curvature of {} at ({u}, {v}); the moved \
1115 face would fold over itself",
1116 1.0 / k.abs()
1117 );
1118 }
1119 }
1120 }
1121 }
1122
1123 let moved_at = |u: f64, v: f64| -> OgeomResult<Option<Point>> {
1124 let p = face.surface.point_at(u, v, tol)?;
1125 Ok(face
1126 .surface
1127 .normal_at(u, v, tol)
1128 .ok()
1129 .map(|n| p + n.vector() * along))
1130 };
1131
1132 if let Some(parallel) = OffsetSurface::new(base.clone(), total)?.analytic(tol)? {
1134 let window = (Point2::new(u0, v0), Point2::new(u1, v1));
1138 let mut pole_columns = Vec::new();
1139 let mut pole_rows = Vec::new();
1140 for k in 0..=PROBES {
1141 let (u, v) = probe(k, k);
1142 pole_columns.push(collapsed(base, Some(u), None, window, tol)?);
1143 pole_rows.push(collapsed(base, None, Some(v), window, tol)?);
1144 }
1145 let mut worst = 0.0_f64;
1146 for i in 0..=PROBES {
1147 for j in 0..=PROBES {
1148 let (u, v) = probe(i, j);
1149 if pole_columns[i as usize] || pole_rows[j as usize] {
1150 continue;
1151 }
1152 if let Some(want) = moved_at(u, v)? {
1153 worst = worst.max(parallel.point_at(u, v, tol)?.distance(want));
1154 }
1155 }
1156 }
1157 if worst <= tol.confusion() * 10.0 {
1158 return Ok((parallel, true, 0.0));
1159 }
1160 }
1161
1162 let corners = chart_corners(base, face.window);
1163
1164 let mut spans: u32 = 8;
1168 let mut best: Option<(SurfaceGeometry, f64)> = None;
1169 loop {
1170 let n = spans;
1171 let at = |k: u32, lo: f64, hi: f64| lo + (hi - lo) * f64::from(k) / f64::from(2 * n);
1172 let mut fine: Vec<Vec<Point>> = Vec::with_capacity((2 * n + 1) as usize);
1173 for j in 0..=2 * n {
1174 let v = at(j, v0, v1);
1175 let mut row = Vec::with_capacity((2 * n + 1) as usize);
1176 for i in 0..=2 * n {
1177 let u = at(i, u0, u1);
1178 let Some(p) = moved_at(u, v)? else {
1179 ogeom_bail!(
1180 Construction,
1181 "a free-form face has no normal at ({u}, {v}); its \
1182 offset there is not determined"
1183 );
1184 };
1185 row.push(p);
1186 }
1187 fine.push(row);
1188 }
1189 let us: Vec<f64> = (0..=n).map(|i| at(2 * i, u0, u1)).collect();
1190 let vs: Vec<f64> = (0..=n).map(|j| at(2 * j, v0, v1)).collect();
1191 let rows: Vec<Vec<Point>> = (0..=n as usize)
1192 .map(|j| (0..=n as usize).map(|i| fine[2 * j][2 * i]).collect())
1193 .collect();
1194 let held = |kept: &[(f64, usize)]| 4 + 3 * kept.len() <= n as usize + 1;
1197 let kept_u: &[(f64, usize)] = if held(&corners.0) { &corners.0 } else { &[] };
1198 let kept_v: &[(f64, usize)] = if held(&corners.1) { &corners.1 } else { &[] };
1199 let fitted = ogeom_geom::fit::fit_surface_grid_at_with_knots(
1200 &us,
1201 &vs,
1202 &rows,
1203 3,
1204 target * 0.5,
1205 (kept_u, kept_v),
1206 tol,
1207 )?;
1208 let mut surface: SurfaceGeometry = fitted.curve.into();
1209 let mut worst = fitted.error;
1210 for j in 0..=2 * n {
1211 for i in 0..=2 * n {
1212 let want = fine[j as usize][i as usize];
1213 let (u, v) = (at(i, u0, u1), at(j, v0, v1));
1214 worst = worst.max(surface.point_at(u, v, tol)?.distance(want));
1215 }
1216 }
1217 if best.as_ref().is_none_or(|(_, b)| worst < *b) {
1218 best = Some((surface.clone(), worst));
1219 }
1220 let finest = spans >= 128;
1221 if worst > target && finest {
1222 let Some((closest, reached)) = best.take() else {
1223 ogeom_bail!(NotDone, "the offset of a free-form face was not fitted");
1224 };
1225 if reached > bound {
1226 ogeom_bail!(
1227 NotDone,
1228 "the offset of a free-form face did not fit within {bound}; \
1229 the closest fit was {reached} off"
1230 );
1231 }
1232 surface = closest;
1233 worst = reached;
1234 }
1235 if worst <= target || finest {
1236 for j in (0..=2 * n).step_by(2) {
1239 for i in (0..=2 * n).step_by(2) {
1240 let (u, v) = (at(i, u0, u1), at(j, v0, v1));
1241 let (Ok(a), Ok(b)) = (
1242 face.surface.normal_at(u, v, tol),
1243 surface.normal_at(u, v, tol),
1244 ) else {
1245 continue;
1246 };
1247 if a.vector().dot(b.vector()) <= 0.0 {
1248 ogeom_bail!(
1249 Construction,
1250 "an offset of {distance} turns the face over at \
1251 ({u}, {v}); it reaches past the face's radius of \
1252 curvature there"
1253 );
1254 }
1255 }
1256 }
1257 return Ok((surface, false, worst));
1258 }
1259 spans *= 2;
1260 }
1261}
1262
1263type Knots = Vec<(f64, usize)>;
1265
1266fn chart_corners(
1272 surface: &SurfaceGeometry,
1273 ((u0, u1), (v0, v1)): ((f64, f64), (f64, f64)),
1274) -> (Knots, Knots) {
1275 let SurfaceGeometry::BSpline(b) = surface else {
1276 return (Vec::new(), Vec::new());
1277 };
1278 let corners = |knots: &ogeom_math::KnotVector, (lo, hi): (f64, f64)| {
1279 let degree = knots.degree();
1280 knots
1281 .distinct()
1282 .into_iter()
1283 .filter(|&(at, multiplicity)| at > lo && at < hi && multiplicity + 1 >= degree)
1284 .map(|(at, _)| (at, 3))
1285 .collect()
1286 };
1287 (
1288 corners(b.u_knots(), (u0, u1)),
1289 corners(b.v_knots(), (v0, v1)),
1290 )
1291}
1292
1293fn principal_curvatures(
1299 surface: &SurfaceGeometry,
1300 u: f64,
1301 v: f64,
1302 tol: Tolerances,
1303) -> OgeomResult<Option<[f64; 2]>> {
1304 if surface.is_degenerate_at(u, v, tol)? {
1305 return Ok(None);
1306 }
1307 let jet = surface.jet_at(u, v, tol)?;
1308 let c = jet.du.cross(jet.dv);
1309 let scale = jet.du.magnitude().max(jet.dv.magnitude());
1310 if c.magnitude() <= tol.angular() * scale * scale {
1311 return Ok(None);
1312 }
1313 let n = c / c.magnitude();
1314 let (e, f, g) = (jet.du.dot(jet.du), jet.du.dot(jet.dv), jet.dv.dot(jet.dv));
1315 let (l, m, nn) = (jet.d2u.dot(n), jet.duv.dot(n), jet.d2v.dot(n));
1316 let det = e.mul_add(g, -f * f);
1317 let gaussian = l.mul_add(nn, -m * m) / det;
1318 let mean = (e * nn - 2.0 * f * m + g * l) / (2.0 * det);
1319 let spread = mean.mul_add(mean, -gaussian).max(0.0).sqrt();
1320 Ok(Some([mean + spread, mean - spread]))
1321}
1322
1323fn exact_moved_curve(
1327 curve: &Curve,
1328 range: (f64, f64),
1329 off: &dyn Fn(f64) -> OgeomResult<Point>,
1330 tol: Tolerances,
1331) -> OgeomResult<Option<Curve>> {
1332 let start = curve.point_at(range.0, tol)?;
1333 let shift = off(range.0)? - start;
1334 let mut candidates = vec![Transform::translation(shift)];
1335 if let Curve::Circle(c) = curve {
1336 let circle = c.circle();
1337 let radius = circle.radius();
1338 let radial = (start - circle.centre()) / radius;
1339 let axis = circle.frame().z().vector();
1340 let grown = radius + shift.dot(radial);
1341 if grown > tol.confusion() {
1342 candidates.push(
1343 Transform::translation(axis * shift.dot(axis))
1344 * Transform::scaling(circle.centre(), grown / radius, tol)?,
1345 );
1346 }
1347 }
1348 for candidate in candidates {
1349 let moved = curve.transformed(&candidate, tol)?;
1350 let mut worst = 0.0_f64;
1351 for k in 0..=4 * PROBES {
1352 let t = range.0 + (range.1 - range.0) * f64::from(k) / f64::from(4 * PROBES);
1353 worst = worst.max(moved.point_at(t, tol)?.distance(off(t)?));
1354 }
1355 if worst <= tol.confusion() * 10.0 {
1356 return Ok(Some(moved));
1357 }
1358 }
1359 Ok(None)
1360}
1361
1362fn fitted_moved_curve(
1367 range: (f64, f64),
1368 off: &dyn Fn(f64) -> OgeomResult<Point>,
1369 (target, bound): (f64, f64),
1370 tol: Tolerances,
1371) -> OgeomResult<(Curve, f64)> {
1372 let mut spans: u32 = 16;
1373 let mut best: Option<(Curve, f64)> = None;
1374 loop {
1375 let at = |k: u32| range.0 + (range.1 - range.0) * f64::from(k) / f64::from(2 * spans);
1376 let mut fine = Vec::with_capacity((2 * spans + 1) as usize);
1377 for k in 0..=2 * spans {
1378 fine.push(off(at(k))?);
1379 }
1380 let params: Vec<f64> = (0..=spans).map(|k| at(2 * k)).collect();
1381 let points: Vec<Point> = (0..=spans as usize).map(|k| fine[2 * k]).collect();
1382 let fitted = ogeom_geom::fit::fit_points_at(¶ms, &points, 3, target * 0.5, tol)?;
1383 let curve: Curve = fitted.curve.into();
1384 let mut worst = fitted.error;
1385 for k in 0..=2 * spans {
1386 let want = fine[k as usize];
1387 worst = worst.max(curve.point_at(at(k), tol)?.distance(want));
1388 }
1389 if worst <= target {
1390 return Ok((curve, worst));
1391 }
1392 if best.as_ref().is_none_or(|(_, b)| worst < *b) {
1393 best = Some((curve, worst));
1394 }
1395 if spans >= 1024 {
1396 return match best {
1397 Some((closest, reached)) if reached <= bound => Ok((closest, reached)),
1398 Some((_, reached)) => ogeom_bail!(
1399 NotDone,
1400 "the offset of an edge did not fit within {bound}; the \
1401 closest fit was {reached} off"
1402 ),
1403 None => ogeom_bail!(NotDone, "the offset of an edge was not fitted"),
1404 };
1405 }
1406 spans *= 2;
1407 }
1408}
1409
1410fn same_parameter(model: &mut Model, edge: &Shape, tol: Tolerances) -> OgeomResult<()> {
1413 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()).cloned() else {
1414 ogeom_bail!(Construction, "edge node holds no edge data");
1415 };
1416 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1417 return Ok(());
1418 };
1419 let Some(curve) = model.geometry().curve(*curve).cloned() else {
1420 ogeom_bail!(Dangling, "curve is not in this model");
1421 };
1422 let mut worst = 0.0_f64;
1423 for repr in &data.representations {
1424 let (ids, surface, own) = match repr {
1425 EdgeRepr::PCurve {
1426 curve,
1427 surface,
1428 range,
1429 ..
1430 } => (vec![*curve], *surface, *range),
1431 EdgeRepr::Seam {
1432 forward,
1433 reversed,
1434 surface,
1435 range,
1436 ..
1437 } => (vec![*forward, *reversed], *surface, *range),
1438 _ => continue,
1439 };
1440 if own != *range {
1443 return Ok(());
1444 }
1445 let Some(surface) = model.geometry().surface(surface).cloned() else {
1446 ogeom_bail!(Dangling, "surface is not in this model");
1447 };
1448 for id in ids {
1449 let Some(p) = model.geometry().pcurve(id).cloned() else {
1450 ogeom_bail!(Dangling, "pcurve is not in this model");
1451 };
1452 for k in 0..=4 * PROBES {
1453 let t = range.0 + (range.1 - range.0) * f64::from(k) / f64::from(4 * PROBES);
1454 let uv = p.point_at(t, tol)?;
1455 let on = surface.point_at(uv.x, uv.y, tol)?;
1456 worst = worst.max(on.distance(curve.point_at(t, tol)?));
1457 }
1458 }
1459 }
1460 if worst > tol.confusion() {
1461 model.widen(edge, Tolerance::new(worst + tol.confusion())?)?;
1462 }
1463 if let Some(node) = model.node_mut(edge)
1464 && let NodeData::Edge(data) = node.data_mut()
1465 {
1466 data.assert_same_parameter(true);
1467 }
1468 Ok(())
1469}
1470
1471#[derive(Default)]
1474struct Creases {
1475 edges: Vec<usize>,
1477 ends: FastMap<TShapeId, (usize, bool)>,
1480}
1481
1482impl Creases {
1483 fn touches(&self, edge: &SheetEdge) -> bool {
1485 self.ends.contains_key(&edge.ends.0) || self.ends.contains_key(&edge.ends.1)
1486 }
1487}
1488
1489fn creases(sheet: &Sheet, reach: f64, tol: Tolerances) -> OgeomResult<Creases> {
1495 let target = tol.approximation();
1496 let mut found = Creases::default();
1497 for (ei, edge) in sheet.edges.iter().enumerate() {
1498 let [a, b] = edge.uses.as_slice() else {
1499 continue;
1500 };
1501 let Some((curve, range)) = &edge.curve else {
1502 continue;
1503 };
1504 if a.face == b.face {
1505 continue;
1506 }
1507 let mut parting = 0.0_f64;
1508 for k in 0..=PROBES {
1509 let t = range.0 + (range.1 - range.0) * f64::from(k) / f64::from(PROBES);
1510 let at = curve.point_at(t, tol)?;
1511 let na = sheet_normal(
1512 &sheet.faces[a.face],
1513 seat_on(sheet, edge, a, (at, t, *range), tol)?,
1514 tol,
1515 );
1516 let nb = sheet_normal(
1517 &sheet.faces[b.face],
1518 seat_on(sheet, edge, b, (at, t, *range), tol)?,
1519 tol,
1520 );
1521 if let (Some(na), Some(nb)) = (na, nb) {
1522 parting = parting.max((na - nb).magnitude());
1523 }
1524 }
1525 if parting * reach / 2.0 > target {
1526 found.edges.push(ei);
1527 }
1528 }
1529 for &ei in &found.edges {
1530 let edge = &sheet.edges[ei];
1531 if edge.ends.0 == edge.ends.1 {
1532 ogeom_bail!(
1533 Construction,
1534 "the sheet's faces meet at a crease that closes on itself; a crease \
1535 is joined where it runs from one border of the sheet to another"
1536 );
1537 }
1538 let walked = |u: &EdgeUse| {
1539 sheet.faces[u.face]
1540 .occurrence
1541 .orientation()
1542 .compose(u.sense)
1543 };
1544 if walked(&edge.uses[0]) == walked(&edge.uses[1]) {
1545 ogeom_bail!(
1546 Construction,
1547 "the sheet's faces walk a crease the same way, so they disagree on \
1548 which side of the sheet they face; orient the sheet first"
1549 );
1550 }
1551 let mut sides: Vec<usize> = edge.uses.iter().map(|u| u.face).collect();
1552 sides.sort_unstable();
1553 for (vertex, at_end) in [(edge.ends.0, false), (edge.ends.1, true)] {
1554 let meeting: Vec<&SheetEdge> = sheet
1555 .edges
1556 .iter()
1557 .enumerate()
1558 .filter(|(i, e)| *i != ei && (e.ends.0 == vertex || e.ends.1 == vertex))
1559 .map(|(_, e)| e)
1560 .collect();
1561 let mut faces: Vec<usize> = meeting
1562 .iter()
1563 .filter(|e| e.uses.len() == 1)
1564 .map(|e| e.uses[0].face)
1565 .collect();
1566 faces.sort_unstable();
1567 if meeting.len() != 2 || faces != sides {
1568 ogeom_bail!(
1569 Construction,
1570 "the sheet's faces meet at a crease that ends where more than \
1571 its two faces' borders meet; a crease is joined where it runs \
1572 from one border of the sheet to another"
1573 );
1574 }
1575 found.ends.insert(vertex, (ei, at_end));
1576 }
1577 }
1578 Ok(found)
1579}
1580
1581fn mitre_at(
1585 sheet: &Sheet,
1586 surfaces: &[MovedSurface],
1587 edge: &SheetEdge,
1588 t: f64,
1589 tol: Tolerances,
1590) -> OgeomResult<Point> {
1591 let Some((curve, range)) = &edge.curve else {
1592 ogeom_bail!(Construction, "a crease has no curve in space");
1593 };
1594 let [a, b] = edge.uses.as_slice() else {
1595 ogeom_bail!(Construction, "a crease is shared by two faces");
1596 };
1597 let at = curve.point_at(t, tol)?;
1598 let seat_a = seat_on(sheet, edge, a, (at, t, *range), tol)?;
1599 let seat_b = seat_on(sheet, edge, b, (at, t, *range), tol)?;
1600 let (Some(na), Some(nb)) = (
1601 sheet_normal(&sheet.faces[a.face], seat_a, tol),
1602 sheet_normal(&sheet.faces[b.face], seat_b, tol),
1603 ) else {
1604 ogeom_bail!(
1605 Construction,
1606 "a crease runs into a point where a face has no normal; the mitre \
1607 there is not determined"
1608 );
1609 };
1610 if 1.0 + na.dot(nb) <= 1e-6 {
1611 ogeom_bail!(
1612 Construction,
1613 "the sheet folds back onto itself at a crease; the offsets of its \
1614 two faces never meet"
1615 );
1616 }
1617 let along = curve.d1_at(t, tol)?;
1618 let speed = along.magnitude();
1619 if speed <= f64::MIN_POSITIVE {
1620 ogeom_bail!(Construction, "a crease stands still at {at:?}");
1621 }
1622 mitre_point(
1623 (&surfaces[a.face].geometry, &surfaces[b.face].geometry),
1624 (seat_a, seat_b),
1625 at,
1626 along / speed,
1627 tol,
1628 )
1629}
1630
1631fn mitre_point(
1634 (first, second): (&SurfaceGeometry, &SurfaceGeometry),
1635 (seat_a, seat_b): (Point2, Point2),
1636 at: Point,
1637 tangent: Vector,
1638 tol: Tolerances,
1639) -> OgeomResult<Point> {
1640 let mut x = [seat_a.x, seat_a.y, seat_b.x, seat_b.y];
1641 for _ in 0..32 {
1642 let (pa, au, av) = first.point_d1_at(x[0], x[1], tol)?;
1643 let (pb, bu, bv) = second.point_d1_at(x[2], x[3], tol)?;
1644 let gap = pa - pb;
1645 let across = (pa - at).dot(tangent);
1646 if gap.magnitude() <= tol.confusion() * 1e-3 && across.abs() <= tol.confusion() * 1e-3 {
1647 return Ok(pa + (pb - pa) * 0.5);
1648 }
1649 let mut rows = [
1650 [au.x, av.x, -bu.x, -bv.x, -gap.x],
1651 [au.y, av.y, -bu.y, -bv.y, -gap.y],
1652 [au.z, av.z, -bu.z, -bv.z, -gap.z],
1653 [au.dot(tangent), av.dot(tangent), 0.0, 0.0, -across],
1654 ];
1655 let Some(step) = solved(&mut rows) else {
1656 break;
1657 };
1658 for (xi, si) in x.iter_mut().zip(step) {
1659 *xi += si;
1660 }
1661 }
1662 ogeom_bail!(
1663 NotDone,
1664 "the offsets of the faces at a crease were not found to meet beside {at:?}"
1665 )
1666}
1667
1668fn solved(rows: &mut [[f64; 5]; 4]) -> Option<[f64; 4]> {
1672 let scale = rows
1673 .iter()
1674 .flat_map(|r| r[..4].iter())
1675 .fold(0.0_f64, |m, v| m.max(v.abs()));
1676 if scale <= f64::MIN_POSITIVE {
1677 return None;
1678 }
1679 for col in 0..4 {
1680 let pivot = (col..4).max_by(|&i, &j| rows[i][col].abs().total_cmp(&rows[j][col].abs()))?;
1681 if rows[pivot][col].abs() <= 1e-14 * scale {
1682 return None;
1683 }
1684 rows.swap(col, pivot);
1685 let lead = rows[col];
1686 for row in rows.iter_mut().skip(col + 1) {
1687 let f = row[col] / lead[col];
1688 for (v, l) in row.iter_mut().zip(lead).skip(col) {
1689 *v -= f * l;
1690 }
1691 }
1692 }
1693 let mut out = [0.0; 4];
1694 for r in (0..4).rev() {
1695 let mut v = rows[r][4];
1696 for (c, known) in out.iter().enumerate().skip(r + 1) {
1697 v -= rows[r][c] * known;
1698 }
1699 out[r] = v / rows[r][r];
1700 }
1701 Some(out)
1702}
1703
1704fn retrimmed(
1708 curve: &Curve,
1709 range: (f64, f64),
1710 ends: [Option<Point>; 2],
1711 tol: Tolerances,
1712) -> OgeomResult<(Curve, (f64, f64))> {
1713 let mut new = [range.0, range.1];
1714 for (k, end) in ends.iter().enumerate() {
1715 let Some(p) = end else {
1716 continue;
1717 };
1718 let t = match curve {
1719 Curve::Line(line) => {
1720 let axis = line.axis();
1721 (*p - axis.location).dot(axis.direction.vector())
1722 }
1723 _ => {
1724 let mut t = new[k];
1725 for _ in 0..64 {
1726 let c = curve.point_at(t, tol)?;
1727 let d = curve.d1_at(t, tol)?;
1728 let speed = d.dot(d);
1729 if speed <= f64::MIN_POSITIVE {
1730 break;
1731 }
1732 let step = (*p - c).dot(d) / speed;
1733 t += step;
1734 if step.abs() <= 1e-15 * (1.0 + t.abs()) {
1735 break;
1736 }
1737 }
1738 t
1739 }
1740 };
1741 let reached = match curve {
1742 Curve::Line(line) => line.axis().point_at(t),
1743 _ => curve.point_at(t, tol)?,
1744 };
1745 if reached.distance(*p) > tol.confusion() * 10.0 {
1746 ogeom_bail!(
1747 Construction,
1748 "a border ending at a crease does not reach the crease's mitre \
1749 ({:.3e} away); a crease is joined where the borders at its ends \
1750 lie square to it",
1751 reached.distance(*p)
1752 );
1753 }
1754 new[k] = t;
1755 }
1756 if new[1] - new[0] <= tol.parametric() {
1757 ogeom_bail!(
1758 Construction,
1759 "the thickness reaches past a border's length where it meets a \
1760 crease; the faces' offsets cross beyond it"
1761 );
1762 }
1763 Ok(match curve {
1764 Curve::Line(line) => {
1765 let (d0, d1) = curve.domain();
1766 let carried = LineCurve::over(line.axis(), new[0].min(d0), new[1].max(d1))?;
1767 (carried.into(), (new[0], new[1]))
1768 }
1769 Curve::Circle(c) => {
1772 let circle = c.circle();
1773 let start = curve.point_at(new[0], tol)?;
1774 let frame = Frame::new(
1775 circle.centre(),
1776 circle.frame().z(),
1777 Direction::new(start - circle.centre(), tol)?,
1778 tol,
1779 )?;
1780 let restarted = ogeom_math::Circle::new(frame, circle.radius(), tol)?;
1781 (
1782 ogeom_geom::CircleCurve::new(restarted).into(),
1783 (0.0, new[1] - new[0]),
1784 )
1785 }
1786 _ => (curve.clone(), (new[0], new[1])),
1787 })
1788}
1789
1790fn on_branch(
1793 image: PlanarCurve,
1794 t: f64,
1795 near: Point2,
1796 surface: &SurfaceGeometry,
1797 tol: Tolerances,
1798) -> OgeomResult<PlanarCurve> {
1799 let ((ua, ub), (va, vb)) = surface.domain();
1800 let at = image.point_at(t, tol)?;
1801 let whole = |periodic: bool, period: f64, gap: f64| {
1802 if periodic && period > 0.0 {
1803 (gap / period).round() * period
1804 } else {
1805 0.0
1806 }
1807 };
1808 let shift = Vector2::new(
1809 whole(surface.is_periodic_u(), ub - ua, near.x - at.x),
1810 whole(surface.is_periodic_v(), vb - va, near.y - at.y),
1811 );
1812 if shift.x == 0.0 && shift.y == 0.0 {
1813 return Ok(image);
1814 }
1815 image.transformed(&Transform2::translation(shift), tol)
1816}
1817
1818fn riser(
1821 model: &mut Model,
1822 vertex: TShapeId,
1823 (lower, upper): (&Layer, &Layer),
1824 risers: &mut FastMap<TShapeId, Shape>,
1825 tol: Tolerances,
1826) -> OgeomResult<Shape> {
1827 if let Some(found) = risers.get(&vertex) {
1828 return Ok(found.clone());
1829 }
1830 let (from, a) = lower.vertices[&vertex].clone();
1831 let (to, b) = upper.vertices[&vertex].clone();
1832 let segment = LineCurve::segment(a, b, tol)?;
1833 let span = a.distance(b);
1834 let shape = make_edge_between(model, segment.into(), (0.0, span), &from, &to, tol)?.shape;
1835 risers.insert(vertex, shape.clone());
1836 Ok(shape)
1837}
1838
1839fn outward_of(sheet: &Sheet, ei: usize, tol: Tolerances) -> OgeomResult<Vector> {
1842 let edge = &sheet.edges[ei];
1843 let used = &edge.uses[0];
1844 let face = &sheet.faces[used.face];
1845 let Some((curve, range)) = &edge.curve else {
1846 ogeom_bail!(Construction, "a free edge has no curve in space");
1847 };
1848 let mid = f64::midpoint(range.0, range.1);
1849 let uv = seat_on(
1850 sheet,
1851 edge,
1852 used,
1853 (curve.point_at(mid, tol)?, mid, *range),
1854 tol,
1855 )?;
1856 let natural = face.surface.normal_at(uv.x, uv.y, tol)?.vector();
1857 let walked = if used.sense == Orientation::Reversed {
1858 -curve.d1_at(mid, tol)?
1859 } else {
1860 curve.d1_at(mid, tol)?
1861 };
1862 Ok(walked.cross(natural))
1863}
1864
1865fn edge_samples(
1867 model: &Model,
1868 edge: &Shape,
1869 count: i32,
1870 tol: Tolerances,
1871) -> OgeomResult<Vec<Point>> {
1872 let Some(EdgeRepr::Curve3d { curve, range, .. }) = model
1873 .node(edge)
1874 .and_then(|n| n.data().as_edge())
1875 .and_then(EdgeData::curve3d)
1876 else {
1877 ogeom_bail!(Construction, "a layer edge has no curve");
1878 };
1879 let Some(curve) = model.geometry().curve(*curve) else {
1880 ogeom_bail!(Dangling, "curve is not in this model");
1881 };
1882 (0..=count)
1883 .map(|k| {
1884 curve.point_at(
1885 range.0 + (range.1 - range.0) * f64::from(k) / f64::from(count),
1886 tol,
1887 )
1888 })
1889 .collect()
1890}
1891
1892fn flat_side_face(
1896 model: &mut Model,
1897 sheet: &Sheet,
1898 ei: usize,
1899 (lower, upper): (&Layer, &Layer),
1900 risers: &mut FastMap<TShapeId, Shape>,
1901 tol: Tolerances,
1902) -> OgeomResult<Shape> {
1903 let edge = &sheet.edges[ei];
1904 let rise_start = riser(model, edge.ends.0, (lower, upper), risers, tol)?;
1905 let rise_end = riser(model, edge.ends.1, (lower, upper), risers, tol)?;
1906 let low = lower.edges[&edge.node].clone();
1907 let high = upper.edges[&edge.node].clone();
1908 let mut walk = edge_samples(model, &low, 4 * PROBES, tol)?;
1912 let mut back = edge_samples(model, &high, 4 * PROBES, tol)?;
1913 back.reverse();
1914 walk.extend(back);
1915 let mut turning = Vector::ZERO;
1916 for (k, p) in walk.iter().enumerate() {
1917 let q = walk[(k + 1) % walk.len()];
1918 turning += Vector::new(
1919 (p.y - q.y) * (p.z + q.z),
1920 (p.z - q.z) * (p.x + q.x),
1921 (p.x - q.x) * (p.y + q.y),
1922 );
1923 }
1924 #[allow(clippy::cast_precision_loss, reason = "a few dozen samples")]
1925 let count = walk.len() as f64;
1926 let centre = walk
1927 .iter()
1928 .fold(Point::ORIGIN, |acc, p| acc + (*p - Point::ORIGIN) / count);
1929 let normal = Direction::new(turning, tol)?;
1930 let flat = walk
1931 .iter()
1932 .map(|p| (*p - centre).dot(normal.vector()).abs())
1933 .fold(0.0_f64, f64::max);
1934 if flat > tol.confusion() * 10.0 {
1935 ogeom_bail!(
1936 Construction,
1937 "the side along a border ending at a crease is not flat ({flat:.3e} \
1938 out of its plane); a crease is joined where the borders at its ends \
1939 and their offsets lie in one plane"
1940 );
1941 }
1942 let plane =
1943 ogeom_math::Plane::new(Frame::new(centre, normal, normal.any_perpendicular(), tol)?);
1944 let bare = make_face_with_pcurves(
1945 model,
1946 PlaneSurface::new(plane).into(),
1947 &[vec![
1948 low.clone(),
1949 rise_end.clone(),
1950 high.reversed(),
1951 rise_start.reversed(),
1952 ]],
1953 tol,
1954 )?
1955 .shape;
1956 for e in [&low, &high, &rise_start, &rise_end] {
1957 same_parameter(model, e, tol)?;
1958 }
1959 Ok(if normal.vector().dot(outward_of(sheet, ei, tol)?) >= 0.0 {
1960 bare
1961 } else {
1962 bare.reversed()
1963 })
1964}
1965
1966#[allow(
1970 clippy::too_many_lines,
1971 reason = "one construction: chart, edges, pcurves, orientation"
1972)]
1973fn side_face(
1974 model: &mut Model,
1975 sheet: &Sheet,
1976 ei: usize,
1977 (lo, hi): (f64, f64),
1978 (lower, upper): (&Layer, &Layer),
1979 risers: &mut FastMap<TShapeId, Shape>,
1980 tol: Tolerances,
1981) -> OgeomResult<Shape> {
1982 let target = tol.approximation();
1983 let edge = &sheet.edges[ei];
1984 let used = &edge.uses[0];
1985 let face = &sheet.faces[used.face];
1986 let bound = fit_bound(face.tolerance, hi - lo, tol);
1987 let Some((curve, range)) = edge.curve.clone() else {
1988 ogeom_bail!(Construction, "a free edge has no curve in space");
1989 };
1990 let (t0, t1) = range;
1991 let height = hi - lo;
1992 let normal_at = |t: f64| -> OgeomResult<(Point, Vector)> {
1994 let at = curve.point_at(t, tol)?;
1995 let uv = seat_on(sheet, edge, used, (at, t, range), tol)?;
1996 let Some(n) = sheet_normal(face, uv, tol) else {
1997 ogeom_bail!(
1998 Construction,
1999 "a free edge runs into a point where its face has no \
2000 normal; the side there is not determined"
2001 );
2002 };
2003 Ok((at, n))
2004 };
2005 let ruled = |t: f64, s: f64| -> OgeomResult<Point> {
2006 let (at, n) = normal_at(t)?;
2007 Ok(at + n * (lo + s))
2008 };
2009
2010 let measure = |surface: &SurfaceGeometry, v0: f64, sense: f64| -> OgeomResult<f64> {
2013 let mut worst = 0.0_f64;
2014 for i in 0..=4 * PROBES {
2015 let t = t0 + (t1 - t0) * f64::from(i) / f64::from(4 * PROBES);
2016 for s in [0.0, 0.5 * height, height] {
2017 let p = surface.point_at(t, sense.mul_add(s, v0), tol)?;
2018 worst = worst.max(p.distance(ruled(t, s)?));
2019 }
2020 }
2021 Ok(worst)
2022 };
2023 let mut chart: Option<(SurfaceGeometry, f64, f64, f64)> = None;
2024 let (_, n0) = normal_at(t0)?;
2025 match &curve {
2026 Curve::Line(line) => {
2027 let axis = line.axis();
2028 let across = Direction::new(axis.direction.vector().cross(n0), tol)?;
2029 let frame = Frame::new(axis.location + n0 * lo, across, axis.direction, tol)?;
2030 let pad = (t1 - t0).abs() + height;
2031 let plane: SurfaceGeometry = PlaneSurface::over(
2032 ogeom_math::Plane::new(frame),
2033 (t0 - pad, t1 + pad),
2034 (-pad, height + pad),
2035 )?
2036 .into();
2037 if measure(&plane, 0.0, 1.0)? <= tol.confusion() * 10.0 {
2038 chart = Some((plane, 0.0, 1.0, 0.0));
2039 }
2040 }
2041 Curve::Circle(c) => {
2042 let circle = c.circle();
2043 let axis = circle.frame().z().vector();
2044 let sense = if n0.dot(axis) >= 0.0 { 1.0 } else { -1.0 };
2045 let (a, b) = (sense * lo, sense * hi);
2046 let cylinder: SurfaceGeometry = CylinderSurface::new(
2047 Cylinder::new(circle.frame(), circle.radius(), tol)?,
2048 (a.min(b) - height, a.max(b) + height),
2049 )?
2050 .into();
2051 if measure(&cylinder, sense * lo, sense)? <= tol.confusion() * 10.0 {
2052 chart = Some((cylinder, sense * lo, sense, 0.0));
2053 }
2054 }
2055 _ => {}
2056 }
2057 let (surface, v0, sense, deviation) = match chart {
2058 Some(found) => found,
2059 None => {
2060 let mut spans: u32 = 16;
2061 let mut best: Option<(SurfaceGeometry, f64)> = None;
2062 loop {
2063 let at = |k: u32| t0 + (t1 - t0) * f64::from(k) / f64::from(2 * spans);
2064 let us: Vec<f64> = (0..=spans).map(|k| at(2 * k)).collect();
2065 let rows: Vec<Vec<Point>> = [0.0, height]
2066 .iter()
2067 .map(|s| us.iter().map(|t| ruled(*t, *s)).collect())
2068 .collect::<OgeomResult<_>>()?;
2069 let fitted = ogeom_geom::fit::fit_surface_grid_at(
2070 &us,
2071 &[0.0, height],
2072 &rows,
2073 3,
2074 target * 0.5,
2075 tol,
2076 )?;
2077 let fit: SurfaceGeometry = fitted.curve.into();
2078 let worst = fitted.error.max(measure(&fit, 0.0, 1.0)?);
2079 if worst <= target {
2080 break (fit, 0.0, 1.0, worst);
2081 }
2082 if best.as_ref().is_none_or(|(_, b)| worst < *b) {
2083 best = Some((fit, worst));
2084 }
2085 if spans >= 1024 {
2086 match best {
2087 Some((closest, reached)) if reached <= bound => {
2088 break (closest, 0.0, 1.0, reached);
2089 }
2090 Some((_, reached)) => ogeom_bail!(
2091 NotDone,
2092 "the side face along a free edge did not fit within \
2093 {bound}; the closest fit was {reached} off"
2094 ),
2095 None => ogeom_bail!(NotDone, "the side face was not fitted"),
2096 }
2097 }
2098 spans *= 2;
2099 }
2100 }
2101 };
2102 let side_id = model.geometry_mut().add_surface(surface.clone());
2103
2104 let rise_start = riser(model, edge.ends.0, (lower, upper), risers, tol)?;
2106 let rise_end = riser(model, edge.ends.1, (lower, upper), risers, tol)?;
2107 let low = lower.edges[&edge.node].clone();
2108 let high = upper.edges[&edge.node].clone();
2109
2110 let forward = Orientation::Forward;
2115 let backward = Orientation::Reversed;
2116 let walk: Vec<(Shape, Orientation)> = if sense > 0.0 {
2117 vec![
2118 (low.clone(), forward),
2119 (rise_end.clone(), forward),
2120 (high.clone(), backward),
2121 (rise_start.clone(), backward),
2122 ]
2123 } else {
2124 vec![
2125 (rise_start.clone(), forward),
2126 (high.clone(), forward),
2127 (rise_end.clone(), backward),
2128 (low.clone(), backward),
2129 ]
2130 };
2131 let ring: Vec<Shape> = walk.iter().map(|(e, o)| e.oriented(*o)).collect();
2132 let wire = make_wire(model, &ring, tol)?.shape;
2133 let bare = make_face_on(model, side_id, std::slice::from_ref(&wire), tol)?.shape;
2134
2135 let row = |v: f64| -> OgeomResult<PlanarCurve> {
2136 Ok(Line2d::over(Axis2::new(Point2::new(0.0, v), Direction2::X), t0, t1)?.into())
2137 };
2138 let up = Direction2::new(ogeom_math::Vector2::new(0.0, sense), tol)?;
2139 let column = |t: f64, length: f64| -> OgeomResult<PlanarCurve> {
2140 Ok(Line2d::over(Axis2::new(Point2::new(t, v0), up), 0.0, length)?.into())
2141 };
2142 let identity = Location::identity();
2143 attach_pcurve(model, &low, row(v0)?, side_id, identity.clone(), range)?;
2144 attach_pcurve(
2145 model,
2146 &high,
2147 row(sense.mul_add(height, v0))?,
2148 side_id,
2149 identity.clone(),
2150 range,
2151 )?;
2152 let length_of = |model: &Model, e: &Shape| -> OgeomResult<f64> {
2153 match model
2154 .node(e)
2155 .and_then(|n| n.data().as_edge())
2156 .and_then(EdgeData::curve3d)
2157 {
2158 Some(EdgeRepr::Curve3d { range, .. }) => Ok(range.1),
2159 _ => ogeom_bail!(Construction, "a riser has no curve"),
2160 }
2161 };
2162 if rise_start.node() == rise_end.node() {
2163 let length = length_of(model, &rise_start)?;
2166 let (climbed, descended) = if sense > 0.0 { (t1, t0) } else { (t0, t1) };
2167 attach_seam(
2168 model,
2169 &rise_start,
2170 column(climbed, length)?,
2171 column(descended, length)?,
2172 side_id,
2173 identity,
2174 (0.0, length),
2175 )?;
2176 } else {
2177 let length = length_of(model, &rise_start)?;
2178 attach_pcurve(
2179 model,
2180 &rise_start,
2181 column(t0, length)?,
2182 side_id,
2183 identity.clone(),
2184 (0.0, length),
2185 )?;
2186 let length = length_of(model, &rise_end)?;
2187 attach_pcurve(
2188 model,
2189 &rise_end,
2190 column(t1, length)?,
2191 side_id,
2192 identity,
2193 (0.0, length),
2194 )?;
2195 }
2196 for e in [&low, &high, &rise_start, &rise_end] {
2197 same_parameter(model, e, tol)?;
2198 }
2199 if deviation > 0.0 {
2200 model.widen(&bare, Tolerance::new(deviation + tol.confusion())?)?;
2201 }
2202
2203 let mid = f64::midpoint(t0, t1);
2204 let outward = outward_of(sheet, ei, tol)?;
2205 let side_normal = surface
2206 .normal_at(mid, sense.mul_add(0.5 * height, v0), tol)?
2207 .vector();
2208 Ok(if side_normal.dot(outward) >= 0.0 {
2209 bare
2210 } else {
2211 bare.reversed()
2212 })
2213}