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 state_new_gaps(model, sheet, &shape, tol)?;
117 let mut history = layer.history;
118 if !read.root_is_face {
119 history.modify(&read.root, shape.clone());
120 }
121 if layer.faces.len() > 1 && !ogeom_algo::check_self_intersection(model, &shape, tol)?.is_empty()
122 {
123 ogeom_bail!(
124 Construction,
125 "the offset sheet runs into itself; the distance is larger than \
126 the room between its faces"
127 );
128 }
129 Ok(Built::new(shape, with_prefix(read.prefix, history)))
130}
131
132pub fn make_thick_sheet(
161 model: &mut Model,
162 sheet: &Shape,
163 thickness: f64,
164 both_sides: bool,
165 tol: Tolerances,
166) -> OgeomResult<Built> {
167 if !thickness.is_finite() || thickness.abs() <= tol.confusion() {
168 ogeom_bail!(Construction, "a thickness of {thickness} holds nothing");
169 }
170 let read = read_sheet(model, sheet, tol)?;
171 if read.faces.iter().any(|f| f.natural) {
172 ogeom_bail!(
173 Construction,
174 "a face with no boundary edges has nothing to close the sides \
175 along; bound it with edges first"
176 );
177 }
178 let free: Vec<usize> = (0..read.edges.len())
179 .filter(|&i| read.edges[i].uses.len() == 1 && !read.edges[i].data.degenerate)
180 .collect();
181 if free.is_empty() {
182 ogeom_bail!(
183 Construction,
184 "the sheet has no free edge; a closed shell is hollowed with \
185 make_thick_solid, not thickened"
186 );
187 }
188 let (lo, hi) = if both_sides {
189 (-thickness.abs() / 2.0, thickness.abs() / 2.0)
190 } else if thickness > 0.0 {
191 (0.0, thickness)
192 } else {
193 (thickness, 0.0)
194 };
195 let creases = creases(&read, lo.abs().max(hi.abs()), tol)?;
196 let lower = moved_layer(model, &read, lo, Some(&creases), tol)?;
197 let upper = moved_layer(model, &read, hi, Some(&creases), tol)?;
198
199 let mut history = History::new();
200 let mut faces: Vec<Shape> = Vec::new();
201 for (fi, face) in read.faces.iter().enumerate() {
202 faces.push(upper.faces[fi].clone());
205 faces.push(lower.faces[fi].reversed());
206 history.generate(&face.occurrence, upper.faces[fi].clone());
207 history.generate(&face.occurrence, lower.faces[fi].reversed());
208 }
209 let mut risers: FastMap<TShapeId, Shape> = FastMap::default();
210 for ei in free {
211 let side = if creases.touches(&read.edges[ei]) {
212 flat_side_face(model, &read, ei, (&lower, &upper), &mut risers, tol)?
213 } else {
214 side_face(
215 model,
216 &read,
217 ei,
218 (lo, hi),
219 (&lower, &upper),
220 &mut risers,
221 tol,
222 )?
223 };
224 history.generate(&Shape::of(read.edges[ei].node), side.clone());
225 faces.push(side);
226 }
227 let shell = make_shell(model, &faces)?.shape;
228 if !ogeom_algo::is_shell_closed(model, &shell)? {
229 ogeom_bail!(
230 Construction,
231 "the thickened sheet does not close: an edge bounds the sheet \
232 more than once in a way the side faces cannot follow"
233 );
234 }
235 let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
236 if !ogeom_algo::check_self_intersection(model, &solid, tol)?.is_empty() {
240 ogeom_bail!(
241 Construction,
242 "the thickened sheet runs into itself; the thickness is larger \
243 than the room between its faces"
244 );
245 }
246 if !ogeom_algo::inside_out_faces(model, &solid, tol)?.is_empty() {
247 ogeom_bail!(
248 Construction,
249 "the thickened sheet folds through itself and turns faces inside out"
250 );
251 }
252 if !read.root_is_face {
253 history.generate(&read.root, solid.clone());
254 }
255 state_new_gaps(model, sheet, &solid, tol)?;
260 Ok(Built::new(solid, with_prefix(read.prefix, history)))
261}
262
263fn with_prefix(prefix: Option<History>, history: History) -> History {
264 match prefix {
265 Some(prefix) => prefix.then(&history),
266 None => history,
267 }
268}
269
270struct SheetFace {
272 occurrence: Shape,
273 surface_id: SurfaceId,
274 surface: SurfaceGeometry,
275 sign: f64,
277 natural: bool,
278 tolerance: f64,
280 wires: Vec<(Orientation, Vec<(TShapeId, Orientation)>)>,
282 window: ((f64, f64), (f64, f64)),
284}
285
286struct EdgeUse {
288 face: usize,
289 sense: Orientation,
291 pcurve: Option<(PlanarCurve, (f64, f64))>,
294}
295
296struct SheetEdge {
298 node: TShapeId,
299 data: EdgeData,
300 curve: Option<(Curve, (f64, f64))>,
302 ends: (TShapeId, TShapeId),
304 uses: Vec<EdgeUse>,
305}
306
307struct Sheet {
309 root: Shape,
310 root_is_face: bool,
311 faces: Vec<SheetFace>,
312 edges: Vec<SheetEdge>,
313 vertices: Vec<SheetVertex>,
315 prefix: Option<History>,
316}
317
318type SheetVertex = (TShapeId, Point, Seats);
321
322type Seats = Vec<(usize, Point2)>;
324
325fn read_sheet(model: &mut Model, sheet: &Shape, tol: Tolerances) -> OgeomResult<Sheet> {
327 let kind = model.kind_of(sheet)?;
328 match kind {
329 ShapeType::Face | ShapeType::Shell => {}
330 ShapeType::Solid | ShapeType::CompSolid => ogeom_bail!(
331 Construction,
332 "a solid is offset with offset_shape and hollowed with \
333 make_thick_solid; a sheet is a face or a shell"
334 ),
335 other => ogeom_bail!(
336 Construction,
337 "a sheet is a face or a shell, not a {other:?}"
338 ),
339 }
340 let (root, prefix) = if placed(model, sheet)? {
341 baked_sheet(model, sheet, kind, tol)?
342 } else {
343 (sheet.clone(), None)
344 };
345
346 let mut faces: Vec<SheetFace> = Vec::new();
347 let mut seen: Vec<TShapeId> = Vec::new();
348 for occurrence in explore(model, &root, Filter::OfType(ShapeType::Face))? {
349 if seen.contains(&occurrence.node()) {
350 ogeom_bail!(Construction, "the sheet holds one face twice");
351 }
352 seen.push(occurrence.node());
353 let Some(node) = model.node(&occurrence) else {
354 ogeom_bail!(Dangling, "face is not in this model");
355 };
356 let NodeData::Face(data) = node.data() else {
357 ogeom_bail!(Construction, "face node holds no face data");
358 };
359 let Some(surface) = model.geometry().surface(data.surface).cloned() else {
360 ogeom_bail!(Dangling, "face refers to a surface not in this model");
361 };
362 let mut wires = Vec::new();
363 for wire in node.children() {
364 let Some(wire_node) = model.node(wire) else {
365 ogeom_bail!(Dangling, "wire is not in this model");
366 };
367 let edges = wire_node
368 .children()
369 .iter()
370 .map(|e| (e.node(), e.orientation()))
371 .collect();
372 wires.push((wire.orientation(), edges));
373 }
374 faces.push(SheetFace {
375 occurrence: occurrence.clone(),
376 surface_id: data.surface,
377 sign: if occurrence.orientation() == Orientation::Reversed {
378 -1.0
379 } else {
380 1.0
381 },
382 natural: data.natural_restriction || wires.is_empty(),
383 tolerance: data.tolerance.get(),
384 surface,
385 wires,
386 window: ((0.0, 0.0), (0.0, 0.0)),
387 });
388 }
389 if faces.is_empty() {
390 ogeom_bail!(Construction, "the sheet has no faces");
391 }
392
393 let mut edges: Vec<SheetEdge> = Vec::new();
394 let mut edge_index: FastMap<TShapeId, usize> = FastMap::default();
395 for (fi, face) in faces.iter().enumerate() {
396 let mut first_ring: Option<(Point2, Point2)> = None;
399 for (wire_sense, list) in &face.wires {
400 let mut ring: Vec<RingUse> = Vec::with_capacity(list.len());
401 for (edge_node, edge_sense) in list {
402 let sense = wire_sense.compose(*edge_sense);
403 let index = if let Some(i) = edge_index.get(edge_node) {
404 *i
405 } else {
406 let bare = Shape::of(*edge_node);
407 let Some(data) = model.node(&bare).and_then(|n| n.data().as_edge()).cloned()
408 else {
409 ogeom_bail!(Construction, "edge node holds no edge data");
410 };
411 let curve = match data.curve3d() {
412 Some(EdgeRepr::Curve3d { curve, range, .. }) => {
413 let Some(geometry) = model.geometry().curve(*curve) else {
414 ogeom_bail!(Dangling, "curve is not in this model");
415 };
416 Some((geometry.clone(), *range))
417 }
418 _ if data.degenerate => None,
419 _ => ogeom_bail!(Construction, "a sheet edge has no curve in space"),
420 };
421 let Some((start, end)) = edge_vertices(model, &bare)? else {
422 ogeom_bail!(Construction, "a sheet edge has no vertices");
423 };
424 edges.push(SheetEdge {
425 node: *edge_node,
426 data,
427 curve,
428 ends: (start.node(), end.node()),
429 uses: Vec::new(),
430 });
431 edge_index.insert(*edge_node, edges.len() - 1);
432 edges.len() - 1
433 };
434 let edge = &edges[index];
435 let pcurve = match edge.data.pcurve_on(face.surface_id) {
436 Some(EdgeRepr::PCurve { curve, range, .. }) => model
437 .geometry()
438 .pcurve(*curve)
439 .cloned()
440 .map(|p| (p, *range)),
441 Some(EdgeRepr::Seam {
442 forward,
443 reversed,
444 range,
445 ..
446 }) => {
447 let id = if sense == Orientation::Reversed {
448 *reversed
449 } else {
450 *forward
451 };
452 model.geometry().pcurve(id).cloned().map(|p| (p, *range))
453 }
454 _ => None,
455 };
456 if pcurve.is_none() && edge.data.degenerate {
457 ogeom_bail!(Construction, "a pole edge has no pcurve to place it");
458 }
459 ring.push(RingUse {
460 edge: index,
461 walked: *edge_sense,
462 sense,
463 pcurve,
464 charted: false,
465 });
466 }
467 chart_bare_uses(&edges, face, &mut ring, &mut first_ring, tol)?;
468 for used in ring {
469 edges[used.edge].uses.push(EdgeUse {
470 face: fi,
471 sense: used.sense,
472 pcurve: used.pcurve,
473 });
474 }
475 }
476 }
477 for edge in &edges {
478 let faces_met: ogeom_core::FastSet<usize> = edge.uses.iter().map(|u| u.face).collect();
479 if edge.uses.len() > 2 {
480 ogeom_bail!(
481 Construction,
482 "an edge is shared by more than two faces; the sheet is not a \
483 surface the offset can move"
484 );
485 }
486 if faces_met.len() == 1 && edge.uses.len() == 2 && edge.uses[0].sense == edge.uses[1].sense
487 {
488 ogeom_bail!(Construction, "a seam is walked the same way twice");
489 }
490 }
491
492 let mut vertices: Vec<SheetVertex> = Vec::new();
494 let mut vertex_index: FastMap<TShapeId, usize> = FastMap::default();
495 let mut seat = |model: &Model,
496 vertex: TShapeId,
497 face: usize,
498 uv: Option<Point2>,
499 surface: &SurfaceGeometry|
500 -> OgeomResult<()> {
501 let index = if let Some(i) = vertex_index.get(&vertex) {
502 *i
503 } else {
504 let Some(data) = model
505 .node(&Shape::of(vertex))
506 .and_then(|n| n.data().as_vertex())
507 else {
508 ogeom_bail!(Construction, "vertex node holds no point");
509 };
510 vertices.push((vertex, data.point, Vec::new()));
511 vertex_index.insert(vertex, vertices.len() - 1);
512 vertices.len() - 1
513 };
514 let uv = match uv {
515 Some(uv) => uv,
516 None => {
517 let found = ogeom_algo::project_on_surface(surface, vertices[index].1, 32, tol)?;
518 Point2::new(found.parameters.0, found.parameters.1)
519 }
520 };
521 vertices[index].2.push((face, uv));
522 Ok(())
523 };
524 let mut boxes: Vec<Option<(Point2, Point2)>> = vec![None; faces.len()];
525 let grow = |boxes: &mut Vec<Option<(Point2, Point2)>>, face: usize, uv: Point2| {
526 boxes[face] = Some(match boxes[face] {
527 None => (uv, uv),
528 Some((lo, hi)) => (
529 Point2::new(lo.x.min(uv.x), lo.y.min(uv.y)),
530 Point2::new(hi.x.max(uv.x), hi.y.max(uv.y)),
531 ),
532 });
533 };
534 for edge in &edges {
535 for used in &edge.uses {
536 let surface = &faces[used.face].surface;
537 let (start_uv, end_uv) = if let Some((p, range)) = &used.pcurve {
538 for k in 0..=24 {
539 let t = range.0 + (range.1 - range.0) * f64::from(k) / 24.0;
540 grow(&mut boxes, used.face, p.point_at(t, tol)?);
541 }
542 (
543 Some(p.point_at(range.0, tol)?),
544 Some(p.point_at(range.1, tol)?),
545 )
546 } else if let Some((curve, range)) = &edge.curve {
547 for k in 0..=24 {
548 let t = range.0 + (range.1 - range.0) * f64::from(k) / 24.0;
549 let found =
550 ogeom_algo::project_on_surface(surface, curve.point_at(t, tol)?, 32, tol)?;
551 grow(
552 &mut boxes,
553 used.face,
554 Point2::new(found.parameters.0, found.parameters.1),
555 );
556 }
557 (None, None)
558 } else {
559 (None, None)
560 };
561 seat(model, edge.ends.0, used.face, start_uv, surface)?;
562 seat(model, edge.ends.1, used.face, end_uv, surface)?;
563 }
564 }
565 for (fi, face) in faces.iter_mut().enumerate() {
566 let ((ua, ub), (va, vb)) = face.surface.domain();
567 face.window = match boxes[fi] {
568 Some((lo, hi)) if !face.natural => {
569 let mu = (hi.x - lo.x).max(tol.parametric()) * 0.02;
573 let mv = (hi.y - lo.y).max(tol.parametric()) * 0.02;
574 let pole =
578 |u: Option<f64>, v: Option<f64>| collapsed(&face.surface, u, v, (lo, hi), tol);
579 let margin = |m: f64, side: bool| if side { 0.0 } else { m };
580 let (mut u0, mut u1, mut v0, mut v1) = (
581 lo.x - margin(mu, pole(Some(lo.x), None)?),
582 hi.x + margin(mu, pole(Some(hi.x), None)?),
583 lo.y - margin(mv, pole(None, Some(lo.y))?),
584 hi.y + margin(mv, pole(None, Some(hi.y))?),
585 );
586 if !face.surface.is_periodic_u() {
587 u0 = u0.max(ua);
588 u1 = u1.min(ub);
589 }
590 if !face.surface.is_periodic_v() {
591 v0 = v0.max(va);
592 v1 = v1.min(vb);
593 }
594 ((u0, u1), (v0, v1))
595 }
596 _ => ((ua, ub), (va, vb)),
597 };
598 let ((u0, u1), (v0, v1)) = face.window;
599 if ![u0, u1, v0, v1].iter().all(|x| x.is_finite()) || u1 <= u0 || v1 <= v0 {
600 ogeom_bail!(
601 Construction,
602 "a face covers no finite region of its surface's chart"
603 );
604 }
605 }
606 Ok(Sheet {
607 root_is_face: kind == ShapeType::Face,
608 root,
609 faces,
610 edges,
611 vertices,
612 prefix,
613 })
614}
615
616struct RingUse {
618 edge: usize,
619 walked: Orientation,
621 sense: Orientation,
623 pcurve: Option<(PlanarCurve, (f64, f64))>,
624 charted: bool,
626}
627
628fn chart_bare_uses(
638 edges: &[SheetEdge],
639 face: &SheetFace,
640 ring: &mut [RingUse],
641 first_ring: &mut Option<(Point2, Point2)>,
642 tol: Tolerances,
643) -> OgeomResult<()> {
644 let surface = &face.surface;
645 let ends = |used: &RingUse| -> OgeomResult<Option<(Point2, Point2)>> {
646 let Some((p, range)) = &used.pcurve else {
647 return Ok(None);
648 };
649 let (a, b) = (p.point_at(range.0, tol)?, p.point_at(range.1, tol)?);
650 Ok(Some(if used.walked == Orientation::Reversed {
651 (b, a)
652 } else {
653 (a, b)
654 }))
655 };
656 let mut previous = match ring.last() {
657 Some(last) => ends(last)?.map(|(_, end)| end),
658 None => None,
659 };
660 let mut all_charted = true;
661 for used in ring.iter_mut() {
662 if used.pcurve.is_none()
663 && let Some((curve, range)) = &edges[used.edge].curve
664 {
665 let image = match ogeom_intersect::exact_pcurve_over(curve, *range, surface, tol) {
666 Some(exact) => exact,
667 None => {
668 ogeom_algo::pcurve_fit::fit_projected_pcurve(curve, *range, surface, tol)?.0
669 }
670 };
671 let start = if used.walked == Orientation::Reversed {
672 range.1
673 } else {
674 range.0
675 };
676 let image = match previous {
677 Some(near) => on_branch(image, start, near, surface, tol)?,
678 None => image,
679 };
680 used.pcurve = Some((image, *range));
681 used.charted = true;
682 } else {
683 all_charted = false;
684 }
685 if let Some((_, end)) = ends(used)? {
686 previous = Some(end);
687 }
688 }
689 let periodic = surface.is_periodic_u() || surface.is_periodic_v();
690 let mut span: Option<(Point2, Point2)> = None;
691 for used in ring.iter() {
692 if let Some((p, range)) = &used.pcurve {
693 for k in 0..=8 {
694 let t = range.0 + (range.1 - range.0) * f64::from(k) / 8.0;
695 let uv = p.point_at(t, tol)?;
696 span = Some(match span {
697 None => (uv, uv),
698 Some((lo, hi)) => (
699 Point2::new(lo.x.min(uv.x), lo.y.min(uv.y)),
700 Point2::new(hi.x.max(uv.x), hi.y.max(uv.y)),
701 ),
702 });
703 }
704 }
705 }
706 let Some(span) = span else {
707 return Ok(());
708 };
709 match *first_ring {
710 None => *first_ring = Some(span),
711 Some((lo, hi)) if periodic && all_charted => {
712 let middle =
713 |(a, b): (Point2, Point2)| Point2::new((a.x + b.x) / 2.0, (a.y + b.y) / 2.0);
714 let (want, have) = (middle((lo, hi)), middle(span));
715 for used in ring.iter_mut().filter(|u| u.charted) {
716 if let Some((p, range)) = used.pcurve.take() {
717 let at = p.point_at(range.0, tol)?;
718 let near = Point2::new(at.x + want.x - have.x, at.y + want.y - have.y);
719 used.pcurve = Some((on_branch(p, range.0, near, surface, tol)?, range));
720 }
721 }
722 }
723 Some(_) => {}
724 }
725 Ok(())
726}
727
728fn placed(model: &Model, sheet: &Shape) -> OgeomResult<bool> {
731 for kind in [ShapeType::Vertex, ShapeType::Edge, ShapeType::Face] {
732 for occurrence in explore(model, sheet, Filter::OfType(kind))? {
733 if occurrence.transform(model.datums())?.kind() != ogeom_math::TransformKind::Identity {
734 return Ok(true);
735 }
736 let Some(node) = model.node(&occurrence) else {
737 ogeom_bail!(Dangling, "shape is not in this model");
738 };
739 match node.data() {
740 NodeData::Face(data) if !data.location.is_identity() => return Ok(true),
741 NodeData::Edge(data)
742 if data
743 .representations
744 .iter()
745 .any(|r| r.location().is_some_and(|l| !l.is_identity())) =>
746 {
747 return Ok(true);
748 }
749 _ => {}
750 }
751 }
752 }
753 Ok(false)
754}
755
756fn baked_sheet(
759 model: &mut Model,
760 sheet: &Shape,
761 kind: ShapeType,
762 tol: Tolerances,
763) -> OgeomResult<(Shape, Option<History>)> {
764 let shell = if kind == ShapeType::Face {
765 model.add_shell(std::slice::from_ref(sheet))?
766 } else {
767 sheet.clone()
768 };
769 let holder = model.add_solid(std::slice::from_ref(&shell))?;
770 let baked = ogeom_algo::baked_shape(model, &holder, tol)?;
771 let root = if kind == ShapeType::Face {
772 let faces = explore(model, &baked.shape, Filter::OfType(ShapeType::Face))?;
773 let [face] = faces.as_slice() else {
774 ogeom_bail!(Construction, "a placed face did not restate as one face");
775 };
776 face.clone()
777 } else {
778 let shells = explore(model, &baked.shape, Filter::OfType(ShapeType::Shell))?;
779 let [one] = shells.as_slice() else {
780 ogeom_bail!(Construction, "a placed shell did not restate as one shell");
781 };
782 one.clone()
783 };
784 let mut history = baked.history;
785 if history.modified(sheet).is_empty() {
786 history.modify(sheet, root.clone());
787 }
788 Ok((root, Some(history)))
789}
790
791struct MovedSurface {
793 id: SurfaceId,
794 geometry: SurfaceGeometry,
795 exact: bool,
797 deviation: f64,
799}
800
801struct Layer {
803 faces: Vec<Shape>,
805 edges: FastMap<TShapeId, Shape>,
807 vertices: FastMap<TShapeId, (Shape, Point)>,
809 history: History,
810}
811
812fn moved_layer(
821 model: &mut Model,
822 sheet: &Sheet,
823 distance: f64,
824 creases: Option<&Creases>,
825 tol: Tolerances,
826) -> OgeomResult<Layer> {
827 let creases = creases.filter(|c| distance != 0.0 && !c.edges.is_empty());
828 let target = tol.approximation();
829 let mut history = History::new();
830
831 let mut surfaces: Vec<MovedSurface> = Vec::with_capacity(sheet.faces.len());
832 for face in &sheet.faces {
833 let (geometry, exact, deviation) = if distance == 0.0 {
834 (face.surface.clone(), true, 0.0)
835 } else {
836 let bound = fit_bound(face.tolerance, distance, tol);
837 moved_surface(face, distance, (target, bound), tol)?
838 };
839 let id = model.geometry_mut().add_surface(geometry.clone());
840 surfaces.push(MovedSurface {
841 id,
842 geometry,
843 exact,
844 deviation,
845 });
846 }
847
848 let mut vertices: FastMap<TShapeId, (Shape, Point)> = FastMap::default();
850 let mut vertex_slop: FastMap<TShapeId, f64> = FastMap::default();
851 for (node, point, seats) in &sheet.vertices {
852 if let Some(&(ci, at_end)) = creases.and_then(|c| c.ends.get(node)) {
853 let edge = &sheet.edges[ci];
854 let Some((_, range)) = &edge.curve else {
855 ogeom_bail!(Construction, "a crease has no curve in space");
856 };
857 let t = if at_end { range.1 } else { range.0 };
858 let moved = mitre_at(sheet, &surfaces, edge, t, tol)?;
859 let shape = make_vertex(model, moved).shape;
860 history.modify(&Shape::of(*node), shape.clone());
861 vertex_slop.insert(*node, 0.0);
862 vertices.insert(*node, (shape, moved));
863 continue;
864 }
865 let mut candidates = Vec::with_capacity(seats.len());
866 for (fi, uv) in seats {
867 candidates.push(moved_point(
868 sheet, &surfaces, *fi, *uv, *point, distance, tol,
869 )?);
870 }
871 let (moved, spread) = agreed(&candidates, target, "a vertex")?;
872 let shape = make_vertex(model, moved).shape;
873 history.modify(&Shape::of(*node), shape.clone());
874 vertex_slop.insert(*node, spread);
875 vertices.insert(*node, (shape, moved));
876 }
877
878 let mut edges: FastMap<TShapeId, Shape> = FastMap::default();
880 for (ei, edge) in sheet.edges.iter().enumerate() {
881 let is_crease = creases.is_some_and(|c| c.edges.contains(&ei));
882 let cut = creases.map_or([false, false], |c| {
883 [
884 c.ends.contains_key(&edge.ends.0),
885 c.ends.contains_key(&edge.ends.1),
886 ]
887 });
888 let mut recharted: Option<(Curve, (f64, f64))> = None;
889 let (Some((from, from_at)), Some((to, to_at))) = (
890 vertices.get(&edge.ends.0).cloned(),
891 vertices.get(&edge.ends.1).cloned(),
892 ) else {
893 ogeom_bail!(Construction, "an edge end has no moved vertex");
894 };
895 let built = if let Some((curve, range)) = &edge.curve {
896 let off = |t: f64| -> OgeomResult<Point> {
897 let at = curve.point_at(t, tol)?;
898 let mut candidates = Vec::with_capacity(edge.uses.len());
899 for used in &edge.uses {
900 let uv = seat_on(sheet, edge, used, (at, t, *range), tol)?;
901 candidates.push(moved_point(
902 sheet, &surfaces, used.face, uv, at, distance, tol,
903 )?);
904 }
905 Ok(agreed(&candidates, target, "an edge")?.0)
906 };
907 let mitre = |t: f64| mitre_at(sheet, &surfaces, edge, t, tol);
908 let off: &dyn Fn(f64) -> OgeomResult<Point> = if is_crease { &mitre } else { &off };
909 let (moved, slop) = if distance == 0.0 {
910 (curve.clone(), 0.0)
911 } else if let Some(exact) = exact_moved_curve(curve, *range, off, tol)? {
912 (exact, 0.0)
913 } else {
914 let bound = edge
915 .uses
916 .iter()
917 .map(|u| fit_bound(sheet.faces[u.face].tolerance, distance, tol))
918 .fold(target, f64::max);
919 fitted_moved_curve(*range, off, (target, bound), tol)?
920 };
921 let (moved, range) = if !is_crease && cut.contains(&true) {
923 let ends = [cut[0].then_some(from_at), cut[1].then_some(to_at)];
924 retrimmed(&moved, *range, ends, tol)?
925 } else {
926 (moved, *range)
927 };
928 if is_crease || cut.contains(&true) {
929 recharted = Some((moved.clone(), range));
930 }
931 for (t, (vertex, at)) in [(range.0, (&from, from_at)), (range.1, (&to, to_at))] {
934 let gap = moved.point_at(t, tol)?.distance(at);
935 if gap > tol.confusion() {
936 model.widen(vertex, Tolerance::new(gap + tol.confusion())?)?;
937 }
938 }
939 let shape = make_edge_between(model, moved, range, &from, &to, tol)?.shape;
940 if slop > 0.0 {
941 model.widen(&shape, Tolerance::new(slop + tol.confusion())?)?;
942 }
943 shape
944 } else {
945 let mut data = EdgeData::new();
946 data.degenerate = true;
947 model.add_edge(data, &[from.clone(), to.clone()])?
948 };
949 for end in [edge.ends.0, edge.ends.1] {
950 let slop = vertex_slop.get(&end).copied().unwrap_or(0.0);
951 if slop > tol.confusion() {
952 model.widen(&vertices[&end].0, Tolerance::new(slop + tol.confusion())?)?;
953 }
954 }
955 if let Some((curve, range)) = &recharted {
957 for used in &edge.uses {
958 let surface = &surfaces[used.face].geometry;
959 let Some(image) = ogeom_intersect::exact_pcurve_over(curve, *range, surface, tol)
960 else {
961 ogeom_bail!(
962 Construction,
963 "the sheet's faces meet at a crease whose mitre has no exact \
964 image on a moved face; a crease is joined between planes, \
965 cylinders, cones, spheres and tori bounded by lines and circles"
966 );
967 };
968 let near = sheet
969 .vertices
970 .iter()
971 .find(|(node, _, _)| *node == edge.ends.0)
972 .and_then(|(_, _, seats)| seats.iter().find(|(f, _)| *f == used.face))
973 .map(|(_, uv)| *uv);
974 let image = match near {
975 Some(near) => on_branch(image, range.0, near, surface, tol)?,
976 None => image,
977 };
978 let sid = surfaces[used.face].id;
979 attach_pcurve(model, &built, image, sid, Location::identity(), *range)?;
980 }
981 model.widen(&built, edge.data.tolerance)?;
982 same_parameter(model, &built, tol)?;
983 history.modify(&Shape::of(edge.node), built.clone());
984 edges.insert(edge.node, built);
985 continue;
986 }
987 let mut done: Vec<SurfaceId> = Vec::new();
989 for used in &edge.uses {
990 let sid = surfaces[used.face].id;
991 if done.contains(&sid) {
992 continue;
993 }
994 done.push(sid);
995 let old = sheet.faces[used.face].surface_id;
996 match edge.data.pcurve_on(old) {
997 Some(EdgeRepr::PCurve { curve, range, .. }) => {
998 let Some(p) = model.geometry().pcurve(*curve).cloned() else {
999 ogeom_bail!(Dangling, "pcurve is not in this model");
1000 };
1001 attach_pcurve(model, &built, p, sid, Location::identity(), *range)?;
1002 }
1003 Some(EdgeRepr::Seam {
1004 forward,
1005 reversed,
1006 range,
1007 ..
1008 }) => {
1009 let (Some(f), Some(r)) = (
1010 model.geometry().pcurve(*forward).cloned(),
1011 model.geometry().pcurve(*reversed).cloned(),
1012 ) else {
1013 ogeom_bail!(Dangling, "pcurve is not in this model");
1014 };
1015 attach_seam(model, &built, f, r, sid, Location::identity(), *range)?;
1016 }
1017 _ => {
1020 let here: Vec<&EdgeUse> = edge
1021 .uses
1022 .iter()
1023 .filter(|u| surfaces[u.face].id == sid)
1024 .collect();
1025 let image = |sense: Orientation| {
1026 here.iter()
1027 .find(|u| u.sense == sense)
1028 .and_then(|u| u.pcurve.clone())
1029 };
1030 let seam = here.len() == 2 && here[0].face == here[1].face;
1031 match (
1032 seam,
1033 image(Orientation::Forward),
1034 image(Orientation::Reversed),
1035 ) {
1036 (true, Some((f, range)), Some((r, _))) => {
1037 attach_seam(model, &built, f, r, sid, Location::identity(), range)?;
1038 }
1039 (_, Some((p, range)), _) | (_, None, Some((p, range))) => {
1040 attach_pcurve(model, &built, p, sid, Location::identity(), range)?;
1041 }
1042 _ => {}
1043 }
1044 }
1045 }
1046 }
1047 model.widen(&built, edge.data.tolerance)?;
1049 same_parameter(model, &built, tol)?;
1050 history.modify(&Shape::of(edge.node), built.clone());
1051 edges.insert(edge.node, built);
1052 }
1053
1054 let mut faces: Vec<Shape> = Vec::with_capacity(sheet.faces.len());
1056 for (fi, face) in sheet.faces.iter().enumerate() {
1057 let mut wires = Vec::with_capacity(face.wires.len());
1058 for (wire_sense, list) in &face.wires {
1059 let ring: Vec<Shape> = list
1060 .iter()
1061 .map(|(node, sense)| edges[node].oriented(*sense))
1062 .collect();
1063 wires.push(model.add_wire(&ring)?.oriented(*wire_sense));
1064 }
1065 let data = if face.natural {
1066 FaceData::natural(surfaces[fi].id, Location::identity())
1067 } else {
1068 FaceData::new(surfaces[fi].id, Location::identity())
1069 };
1070 let bare = model.add_face(data, &wires)?;
1071 if surfaces[fi].deviation > 0.0 {
1072 model.widen(
1073 &bare,
1074 Tolerance::new(surfaces[fi].deviation + tol.confusion())?,
1075 )?;
1076 }
1077 let shape = bare.oriented(face.occurrence.orientation());
1078 history.modify(&face.occurrence, shape.clone());
1079 faces.push(shape);
1080 }
1081 Ok(Layer {
1082 faces,
1083 edges,
1084 vertices,
1085 history,
1086 })
1087}
1088
1089fn seat_on(
1094 sheet: &Sheet,
1095 edge: &SheetEdge,
1096 used: &EdgeUse,
1097 (at, t, range): (Point, f64, (f64, f64)),
1098 tol: Tolerances,
1099) -> OgeomResult<Point2> {
1100 let surface = &sheet.faces[used.face].surface;
1101 if let Some((p, own)) = &used.pcurve {
1102 let s = if range == *own {
1103 t
1104 } else {
1105 own.0 + (own.1 - own.0) * (t - range.0) / (range.1 - range.0)
1106 };
1107 let uv = p.point_at(s, tol)?;
1108 let reach = edge.data.tolerance.get().max(tol.confusion()) * 10.0;
1109 if surface.point_at(uv.x, uv.y, tol)?.distance(at) <= reach {
1110 return Ok(uv);
1111 }
1112 }
1113 let found = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
1114 Ok(Point2::new(found.parameters.0, found.parameters.1))
1115}
1116
1117fn moved_point(
1122 sheet: &Sheet,
1123 surfaces: &[MovedSurface],
1124 face: usize,
1125 uv: Point2,
1126 at: Point,
1127 distance: f64,
1128 tol: Tolerances,
1129) -> OgeomResult<Point> {
1130 if distance == 0.0 {
1131 return Ok(at);
1132 }
1133 if let Some(n) = sheet_normal(&sheet.faces[face], uv, tol) {
1134 let moved = at + n * distance;
1135 if surfaces[face].exact {
1140 let exact = surfaces[face].geometry.point_at(uv.x, uv.y, tol)?;
1141 if exact.distance(moved) > distance.abs() {
1142 return Ok(exact);
1143 }
1144 }
1145 return Ok(moved);
1146 }
1147 if surfaces[face].exact {
1148 return surfaces[face].geometry.point_at(uv.x, uv.y, tol);
1149 }
1150 ogeom_bail!(
1151 Construction,
1152 "a free-form face has no normal at ({}, {}); its offset there is \
1153 not determined",
1154 uv.x,
1155 uv.y
1156 )
1157}
1158
1159fn sheet_normal(face: &SheetFace, uv: Point2, tol: Tolerances) -> Option<Vector> {
1162 face.surface
1163 .normal_at(uv.x, uv.y, tol)
1164 .ok()
1165 .map(|n| n.vector() * face.sign)
1166}
1167
1168fn agreed(candidates: &[Point], target: f64, what: &str) -> OgeomResult<(Point, f64)> {
1172 let Some(first) = candidates.first() else {
1173 ogeom_bail!(Construction, "{what} bounds no face of the sheet");
1174 };
1175 #[allow(clippy::cast_precision_loss, reason = "a handful of faces")]
1176 let count = candidates.len() as f64;
1177 let sum = candidates
1178 .iter()
1179 .fold(Vector::ZERO, |acc, p| acc + (*p - *first));
1180 let mean = *first + sum / count;
1181 let spread = candidates
1182 .iter()
1183 .fold(0.0_f64, |acc, p| acc.max(p.distance(mean)));
1184 if spread > target {
1185 ogeom_bail!(
1186 Construction,
1187 "the sheet's faces meet at a crease: {what} they share moves \
1188 {} apart on the two sides, which the offset cannot join",
1189 2.0 * spread
1190 );
1191 }
1192 Ok((mean, spread))
1193}
1194
1195fn collapsed(
1199 surface: &SurfaceGeometry,
1200 u: Option<f64>,
1201 v: Option<f64>,
1202 (lo, hi): (Point2, Point2),
1203 tol: Tolerances,
1204) -> OgeomResult<bool> {
1205 let mut points = Vec::with_capacity(9);
1206 for k in 0..=8 {
1207 let t = f64::from(k) / 8.0;
1208 points.push(surface.point_at(
1209 u.unwrap_or(lo.x + (hi.x - lo.x) * t),
1210 v.unwrap_or(lo.y + (hi.y - lo.y) * t),
1211 tol,
1212 )?);
1213 }
1214 let first = points[0];
1215 let sum = points
1216 .iter()
1217 .fold(Vector::ZERO, |acc, p| acc + (*p - first));
1218 let centre = first + sum / 9.0;
1219 Ok(points.iter().all(|p| p.distance(centre) <= tol.confusion()))
1220}
1221
1222const PROBES: i32 = 8;
1224
1225const FIT_SHARE: f64 = 1e-4;
1228
1229pub(crate) fn fit_bound(face_tolerance: f64, distance: f64, tol: Tolerances) -> f64 {
1233 tol.approximation()
1234 .max(face_tolerance)
1235 .max(FIT_SHARE * distance.abs())
1236}
1237
1238fn moved_surface(
1243 face: &SheetFace,
1244 distance: f64,
1245 (target, bound): (f64, f64),
1246 tol: Tolerances,
1247) -> OgeomResult<(SurfaceGeometry, bool, f64)> {
1248 let along = face.sign * distance;
1249 let (base, carried) = match &face.surface {
1251 SurfaceGeometry::Offset(o) => (o.basis(), o.distance()),
1252 other => (other, 0.0),
1253 };
1254 let total = carried + along;
1255 let ((u0, u1), (v0, v1)) = face.window;
1256 let probe = |i: i32, j: i32| {
1257 (
1258 u0 + (u1 - u0) * f64::from(i) / f64::from(PROBES),
1259 v0 + (v1 - v0) * f64::from(j) / f64::from(PROBES),
1260 )
1261 };
1262
1263 folds_within(base, total, distance, face.window, tol)?;
1264
1265 let moved_at = |u: f64, v: f64| -> OgeomResult<Option<Point>> {
1266 let p = face.surface.point_at(u, v, tol)?;
1267 Ok(face
1268 .surface
1269 .normal_at(u, v, tol)
1270 .ok()
1271 .map(|n| p + n.vector() * along))
1272 };
1273
1274 if let Some(parallel) = OffsetSurface::new(base.clone(), total)?.analytic(tol)? {
1276 let window = (Point2::new(u0, v0), Point2::new(u1, v1));
1280 let mut pole_columns = Vec::new();
1281 let mut pole_rows = Vec::new();
1282 for k in 0..=PROBES {
1283 let (u, v) = probe(k, k);
1284 pole_columns.push(collapsed(base, Some(u), None, window, tol)?);
1285 pole_rows.push(collapsed(base, None, Some(v), window, tol)?);
1286 }
1287 let mut worst = 0.0_f64;
1288 for i in 0..=PROBES {
1289 for j in 0..=PROBES {
1290 let (u, v) = probe(i, j);
1291 if pole_columns[i as usize] || pole_rows[j as usize] {
1292 continue;
1293 }
1294 if let Some(want) = moved_at(u, v)? {
1295 worst = worst.max(parallel.point_at(u, v, tol)?.distance(want));
1296 }
1297 }
1298 }
1299 if worst <= tol.confusion() * 10.0 {
1300 return Ok((parallel, true, 0.0));
1301 }
1302 }
1303
1304 fitted_parallel(
1305 &face.surface,
1306 base,
1307 &moved_at,
1308 face.window,
1309 distance,
1310 (target, bound),
1311 tol,
1312 )
1313}
1314
1315pub(crate) fn folds_within(
1319 base: &SurfaceGeometry,
1320 total: f64,
1321 distance: f64,
1322 ((u0, u1), (v0, v1)): ((f64, f64), (f64, f64)),
1323 tol: Tolerances,
1324) -> OgeomResult<()> {
1325 let probe = |i: i32, j: i32| {
1326 (
1327 u0 + (u1 - u0) * f64::from(i) / f64::from(PROBES),
1328 v0 + (v1 - v0) * f64::from(j) / f64::from(PROBES),
1329 )
1330 };
1331 for i in 0..=PROBES {
1332 for j in 0..=PROBES {
1333 let (u, v) = probe(i, j);
1334 let Some(curvatures) = principal_curvatures(base, u, v, tol)? else {
1335 continue;
1336 };
1337 for k in curvatures {
1338 if total * k >= 1.0 - 1e-9 {
1339 ogeom_bail!(
1340 Construction,
1341 "an offset of {distance} reaches past the face's \
1342 radius of curvature of {} at ({u}, {v}); the moved \
1343 face would fold over itself",
1344 1.0 / k.abs()
1345 );
1346 }
1347 }
1348 }
1349 }
1350 Ok(())
1351}
1352
1353pub(crate) fn fitted_parallel(
1360 own: &SurfaceGeometry,
1361 base: &SurfaceGeometry,
1362 moved_at: &dyn Fn(f64, f64) -> OgeomResult<Option<Point>>,
1363 window: ((f64, f64), (f64, f64)),
1364 distance: f64,
1365 (target, bound): (f64, f64),
1366 tol: Tolerances,
1367) -> OgeomResult<(SurfaceGeometry, bool, f64)> {
1368 let ((u0, u1), (v0, v1)) = window;
1369 let corners = chart_corners(base, window);
1370
1371 let mut spans: u32 = 8;
1375 let mut best: Option<(SurfaceGeometry, f64)> = None;
1376 loop {
1377 let n = spans;
1378 let at = |k: u32, lo: f64, hi: f64| lo + (hi - lo) * f64::from(k) / f64::from(2 * n);
1379 let mut fine: Vec<Vec<Point>> = Vec::with_capacity((2 * n + 1) as usize);
1380 for j in 0..=2 * n {
1381 let v = at(j, v0, v1);
1382 let mut row = Vec::with_capacity((2 * n + 1) as usize);
1383 for i in 0..=2 * n {
1384 let u = at(i, u0, u1);
1385 let Some(p) = moved_at(u, v)? else {
1386 ogeom_bail!(
1387 Construction,
1388 "a free-form face has no normal at ({u}, {v}); its \
1389 offset there is not determined"
1390 );
1391 };
1392 row.push(p);
1393 }
1394 fine.push(row);
1395 }
1396 let us: Vec<f64> = (0..=n).map(|i| at(2 * i, u0, u1)).collect();
1397 let vs: Vec<f64> = (0..=n).map(|j| at(2 * j, v0, v1)).collect();
1398 let rows: Vec<Vec<Point>> = (0..=n as usize)
1399 .map(|j| (0..=n as usize).map(|i| fine[2 * j][2 * i]).collect())
1400 .collect();
1401 let held = |kept: &[(f64, usize)]| 4 + 3 * kept.len() <= n as usize + 1;
1404 let kept_u: &[(f64, usize)] = if held(&corners.0) { &corners.0 } else { &[] };
1405 let kept_v: &[(f64, usize)] = if held(&corners.1) { &corners.1 } else { &[] };
1406 let fitted = ogeom_geom::fit::fit_surface_grid_at_with_knots(
1407 &us,
1408 &vs,
1409 &rows,
1410 3,
1411 target * 0.5,
1412 (kept_u, kept_v),
1413 tol,
1414 )?;
1415 let mut surface: SurfaceGeometry = fitted.curve.into();
1416 let mut worst = fitted.error;
1417 for j in 0..=2 * n {
1418 for i in 0..=2 * n {
1419 let want = fine[j as usize][i as usize];
1420 let (u, v) = (at(i, u0, u1), at(j, v0, v1));
1421 worst = worst.max(surface.point_at(u, v, tol)?.distance(want));
1422 }
1423 }
1424 if best.as_ref().is_none_or(|(_, b)| worst < *b) {
1425 best = Some((surface.clone(), worst));
1426 }
1427 let finest = spans >= 128;
1428 if worst > target && finest {
1429 let Some((closest, reached)) = best.take() else {
1430 ogeom_bail!(NotDone, "the offset of a free-form face was not fitted");
1431 };
1432 if reached > bound {
1433 ogeom_bail!(
1434 NotDone,
1435 "the offset of a free-form face did not fit within {bound}; \
1436 the closest fit was {reached} off"
1437 );
1438 }
1439 surface = closest;
1440 worst = reached;
1441 }
1442 if worst <= target || finest {
1443 for j in (0..=2 * n).step_by(2) {
1446 for i in (0..=2 * n).step_by(2) {
1447 let (u, v) = (at(i, u0, u1), at(j, v0, v1));
1448 let (Ok(a), Ok(b)) = (own.normal_at(u, v, tol), surface.normal_at(u, v, tol))
1449 else {
1450 continue;
1451 };
1452 if a.vector().dot(b.vector()) <= 0.0 {
1453 ogeom_bail!(
1454 Construction,
1455 "an offset of {distance} turns the face over at \
1456 ({u}, {v}); it reaches past the face's radius of \
1457 curvature there"
1458 );
1459 }
1460 }
1461 }
1462 return Ok((surface, false, worst));
1463 }
1464 spans *= 2;
1465 }
1466}
1467
1468type Knots = Vec<(f64, usize)>;
1470
1471fn chart_corners(
1477 surface: &SurfaceGeometry,
1478 ((u0, u1), (v0, v1)): ((f64, f64), (f64, f64)),
1479) -> (Knots, Knots) {
1480 let SurfaceGeometry::BSpline(b) = surface else {
1481 return (Vec::new(), Vec::new());
1482 };
1483 let corners = |knots: &ogeom_math::KnotVector, (lo, hi): (f64, f64)| {
1484 let degree = knots.degree();
1485 knots
1486 .distinct()
1487 .into_iter()
1488 .filter(|&(at, multiplicity)| at > lo && at < hi && multiplicity + 1 >= degree)
1489 .map(|(at, _)| (at, 3))
1490 .collect()
1491 };
1492 (
1493 corners(b.u_knots(), (u0, u1)),
1494 corners(b.v_knots(), (v0, v1)),
1495 )
1496}
1497
1498fn principal_curvatures(
1504 surface: &SurfaceGeometry,
1505 u: f64,
1506 v: f64,
1507 tol: Tolerances,
1508) -> OgeomResult<Option<[f64; 2]>> {
1509 if surface.is_degenerate_at(u, v, tol)? {
1510 return Ok(None);
1511 }
1512 let jet = surface.jet_at(u, v, tol)?;
1513 let c = jet.du.cross(jet.dv);
1514 let scale = jet.du.magnitude().max(jet.dv.magnitude());
1515 if c.magnitude() <= tol.angular() * scale * scale {
1516 return Ok(None);
1517 }
1518 let n = c / c.magnitude();
1519 let (e, f, g) = (jet.du.dot(jet.du), jet.du.dot(jet.dv), jet.dv.dot(jet.dv));
1520 let (l, m, nn) = (jet.d2u.dot(n), jet.duv.dot(n), jet.d2v.dot(n));
1521 let det = e.mul_add(g, -f * f);
1522 let gaussian = l.mul_add(nn, -m * m) / det;
1523 let mean = (e * nn - 2.0 * f * m + g * l) / (2.0 * det);
1524 let spread = mean.mul_add(mean, -gaussian).max(0.0).sqrt();
1525 Ok(Some([mean + spread, mean - spread]))
1526}
1527
1528fn exact_moved_curve(
1532 curve: &Curve,
1533 range: (f64, f64),
1534 off: &dyn Fn(f64) -> OgeomResult<Point>,
1535 tol: Tolerances,
1536) -> OgeomResult<Option<Curve>> {
1537 let start = curve.point_at(range.0, tol)?;
1538 let shift = off(range.0)? - start;
1539 let mut candidates = vec![Transform::translation(shift)];
1540 if let Curve::Circle(c) = curve {
1541 let circle = c.circle();
1542 let radius = circle.radius();
1543 let radial = (start - circle.centre()) / radius;
1544 let axis = circle.frame().z().vector();
1545 let grown = radius + shift.dot(radial);
1546 if grown > tol.confusion() {
1547 candidates.push(
1548 Transform::translation(axis * shift.dot(axis))
1549 * Transform::scaling(circle.centre(), grown / radius, tol)?,
1550 );
1551 }
1552 }
1553 for candidate in candidates {
1554 let moved = curve.transformed(&candidate, tol)?;
1555 let mut worst = 0.0_f64;
1556 for k in 0..=4 * PROBES {
1557 let t = range.0 + (range.1 - range.0) * f64::from(k) / f64::from(4 * PROBES);
1558 worst = worst.max(moved.point_at(t, tol)?.distance(off(t)?));
1559 }
1560 if worst <= tol.confusion() * 10.0 {
1561 return Ok(Some(moved));
1562 }
1563 }
1564 Ok(None)
1565}
1566
1567fn fitted_moved_curve(
1572 range: (f64, f64),
1573 off: &dyn Fn(f64) -> OgeomResult<Point>,
1574 (target, bound): (f64, f64),
1575 tol: Tolerances,
1576) -> OgeomResult<(Curve, f64)> {
1577 let mut spans: u32 = 16;
1578 let mut best: Option<(Curve, f64)> = None;
1579 loop {
1580 let at = |k: u32| range.0 + (range.1 - range.0) * f64::from(k) / f64::from(2 * spans);
1581 let mut fine = Vec::with_capacity((2 * spans + 1) as usize);
1582 for k in 0..=2 * spans {
1583 fine.push(off(at(k))?);
1584 }
1585 let params: Vec<f64> = (0..=spans).map(|k| at(2 * k)).collect();
1586 let points: Vec<Point> = (0..=spans as usize).map(|k| fine[2 * k]).collect();
1587 let fitted = ogeom_geom::fit::fit_points_at(¶ms, &points, 3, target * 0.5, tol)?;
1588 let curve: Curve = fitted.curve.into();
1589 let mut worst = fitted.error;
1590 for k in 0..=2 * spans {
1591 let want = fine[k as usize];
1592 worst = worst.max(curve.point_at(at(k), tol)?.distance(want));
1593 }
1594 if worst <= target {
1595 return Ok((curve, worst));
1596 }
1597 if best.as_ref().is_none_or(|(_, b)| worst < *b) {
1598 best = Some((curve, worst));
1599 }
1600 if spans >= 1024 {
1601 return match best {
1602 Some((closest, reached)) if reached <= bound => Ok((closest, reached)),
1603 Some((_, reached)) => ogeom_bail!(
1604 NotDone,
1605 "the offset of an edge did not fit within {bound}; the \
1606 closest fit was {reached} off"
1607 ),
1608 None => ogeom_bail!(NotDone, "the offset of an edge was not fitted"),
1609 };
1610 }
1611 spans *= 2;
1612 }
1613}
1614
1615fn same_parameter(model: &mut Model, edge: &Shape, tol: Tolerances) -> OgeomResult<()> {
1618 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()).cloned() else {
1619 ogeom_bail!(Construction, "edge node holds no edge data");
1620 };
1621 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1622 return Ok(());
1623 };
1624 let Some(curve) = model.geometry().curve(*curve).cloned() else {
1625 ogeom_bail!(Dangling, "curve is not in this model");
1626 };
1627 let mut worst = 0.0_f64;
1628 for repr in &data.representations {
1629 let (ids, surface, own) = match repr {
1630 EdgeRepr::PCurve {
1631 curve,
1632 surface,
1633 range,
1634 ..
1635 } => (vec![*curve], *surface, *range),
1636 EdgeRepr::Seam {
1637 forward,
1638 reversed,
1639 surface,
1640 range,
1641 ..
1642 } => (vec![*forward, *reversed], *surface, *range),
1643 _ => continue,
1644 };
1645 if own != *range {
1648 return Ok(());
1649 }
1650 let Some(surface) = model.geometry().surface(surface).cloned() else {
1651 ogeom_bail!(Dangling, "surface is not in this model");
1652 };
1653 for id in ids {
1654 let Some(p) = model.geometry().pcurve(id).cloned() else {
1655 ogeom_bail!(Dangling, "pcurve is not in this model");
1656 };
1657 for k in 0..=4 * PROBES {
1658 let t = range.0 + (range.1 - range.0) * f64::from(k) / f64::from(4 * PROBES);
1659 let uv = p.point_at(t, tol)?;
1660 let on = surface.point_at(uv.x, uv.y, tol)?;
1661 worst = worst.max(on.distance(curve.point_at(t, tol)?));
1662 }
1663 }
1664 }
1665 if worst > tol.confusion() {
1666 model.widen(edge, Tolerance::new(worst + tol.confusion())?)?;
1667 }
1668 if let Some(node) = model.node_mut(edge)
1669 && let NodeData::Edge(data) = node.data_mut()
1670 {
1671 data.assert_same_parameter(true);
1672 }
1673 Ok(())
1674}
1675
1676#[derive(Default)]
1679struct Creases {
1680 edges: Vec<usize>,
1682 ends: FastMap<TShapeId, (usize, bool)>,
1685}
1686
1687impl Creases {
1688 fn touches(&self, edge: &SheetEdge) -> bool {
1690 self.ends.contains_key(&edge.ends.0) || self.ends.contains_key(&edge.ends.1)
1691 }
1692}
1693
1694fn creases(sheet: &Sheet, reach: f64, tol: Tolerances) -> OgeomResult<Creases> {
1700 let target = tol.approximation();
1701 let mut found = Creases::default();
1702 for (ei, edge) in sheet.edges.iter().enumerate() {
1703 let [a, b] = edge.uses.as_slice() else {
1704 continue;
1705 };
1706 let Some((curve, range)) = &edge.curve else {
1707 continue;
1708 };
1709 if a.face == b.face {
1710 continue;
1711 }
1712 let mut parting = 0.0_f64;
1713 for k in 0..=PROBES {
1714 let t = range.0 + (range.1 - range.0) * f64::from(k) / f64::from(PROBES);
1715 let at = curve.point_at(t, tol)?;
1716 let na = sheet_normal(
1717 &sheet.faces[a.face],
1718 seat_on(sheet, edge, a, (at, t, *range), tol)?,
1719 tol,
1720 );
1721 let nb = sheet_normal(
1722 &sheet.faces[b.face],
1723 seat_on(sheet, edge, b, (at, t, *range), tol)?,
1724 tol,
1725 );
1726 if let (Some(na), Some(nb)) = (na, nb) {
1727 parting = parting.max((na - nb).magnitude());
1728 }
1729 }
1730 if parting * reach / 2.0 > target {
1731 found.edges.push(ei);
1732 }
1733 }
1734 for &ei in &found.edges {
1735 let edge = &sheet.edges[ei];
1736 if edge.ends.0 == edge.ends.1 {
1737 ogeom_bail!(
1738 Construction,
1739 "the sheet's faces meet at a crease that closes on itself; a crease \
1740 is joined where it runs from one border of the sheet to another"
1741 );
1742 }
1743 let walked = |u: &EdgeUse| {
1744 sheet.faces[u.face]
1745 .occurrence
1746 .orientation()
1747 .compose(u.sense)
1748 };
1749 if walked(&edge.uses[0]) == walked(&edge.uses[1]) {
1750 ogeom_bail!(
1751 Construction,
1752 "the sheet's faces walk a crease the same way, so they disagree on \
1753 which side of the sheet they face; orient the sheet first"
1754 );
1755 }
1756 let mut sides: Vec<usize> = edge.uses.iter().map(|u| u.face).collect();
1757 sides.sort_unstable();
1758 for (vertex, at_end) in [(edge.ends.0, false), (edge.ends.1, true)] {
1759 let meeting: Vec<&SheetEdge> = sheet
1760 .edges
1761 .iter()
1762 .enumerate()
1763 .filter(|(i, e)| *i != ei && (e.ends.0 == vertex || e.ends.1 == vertex))
1764 .map(|(_, e)| e)
1765 .collect();
1766 let mut faces: Vec<usize> = meeting
1767 .iter()
1768 .filter(|e| e.uses.len() == 1)
1769 .map(|e| e.uses[0].face)
1770 .collect();
1771 faces.sort_unstable();
1772 if meeting.len() != 2 || faces != sides {
1773 ogeom_bail!(
1774 Construction,
1775 "the sheet's faces meet at a crease that ends where more than \
1776 its two faces' borders meet; a crease is joined where it runs \
1777 from one border of the sheet to another"
1778 );
1779 }
1780 found.ends.insert(vertex, (ei, at_end));
1781 }
1782 }
1783 Ok(found)
1784}
1785
1786fn mitre_at(
1790 sheet: &Sheet,
1791 surfaces: &[MovedSurface],
1792 edge: &SheetEdge,
1793 t: f64,
1794 tol: Tolerances,
1795) -> OgeomResult<Point> {
1796 let Some((curve, range)) = &edge.curve else {
1797 ogeom_bail!(Construction, "a crease has no curve in space");
1798 };
1799 let [a, b] = edge.uses.as_slice() else {
1800 ogeom_bail!(Construction, "a crease is shared by two faces");
1801 };
1802 let at = curve.point_at(t, tol)?;
1803 let seat_a = seat_on(sheet, edge, a, (at, t, *range), tol)?;
1804 let seat_b = seat_on(sheet, edge, b, (at, t, *range), tol)?;
1805 let (Some(na), Some(nb)) = (
1806 sheet_normal(&sheet.faces[a.face], seat_a, tol),
1807 sheet_normal(&sheet.faces[b.face], seat_b, tol),
1808 ) else {
1809 ogeom_bail!(
1810 Construction,
1811 "a crease runs into a point where a face has no normal; the mitre \
1812 there is not determined"
1813 );
1814 };
1815 if 1.0 + na.dot(nb) <= 1e-6 {
1816 ogeom_bail!(
1817 Construction,
1818 "the sheet folds back onto itself at a crease; the offsets of its \
1819 two faces never meet"
1820 );
1821 }
1822 let along = curve.d1_at(t, tol)?;
1823 let speed = along.magnitude();
1824 if speed <= f64::MIN_POSITIVE {
1825 ogeom_bail!(Construction, "a crease stands still at {at:?}");
1826 }
1827 mitre_point(
1828 (&surfaces[a.face].geometry, &surfaces[b.face].geometry),
1829 (seat_a, seat_b),
1830 at,
1831 along / speed,
1832 tol,
1833 )
1834}
1835
1836fn mitre_point(
1839 (first, second): (&SurfaceGeometry, &SurfaceGeometry),
1840 (seat_a, seat_b): (Point2, Point2),
1841 at: Point,
1842 tangent: Vector,
1843 tol: Tolerances,
1844) -> OgeomResult<Point> {
1845 let mut x = [seat_a.x, seat_a.y, seat_b.x, seat_b.y];
1846 for _ in 0..32 {
1847 let (pa, au, av) = first.point_d1_at(x[0], x[1], tol)?;
1848 let (pb, bu, bv) = second.point_d1_at(x[2], x[3], tol)?;
1849 let gap = pa - pb;
1850 let across = (pa - at).dot(tangent);
1851 if gap.magnitude() <= tol.confusion() * 1e-3 && across.abs() <= tol.confusion() * 1e-3 {
1852 return Ok(pa + (pb - pa) * 0.5);
1853 }
1854 let mut rows = [
1855 [au.x, av.x, -bu.x, -bv.x, -gap.x],
1856 [au.y, av.y, -bu.y, -bv.y, -gap.y],
1857 [au.z, av.z, -bu.z, -bv.z, -gap.z],
1858 [au.dot(tangent), av.dot(tangent), 0.0, 0.0, -across],
1859 ];
1860 let Some(step) = solved(&mut rows) else {
1861 break;
1862 };
1863 for (xi, si) in x.iter_mut().zip(step) {
1864 *xi += si;
1865 }
1866 }
1867 ogeom_bail!(
1868 NotDone,
1869 "the offsets of the faces at a crease were not found to meet beside {at:?}"
1870 )
1871}
1872
1873fn solved(rows: &mut [[f64; 5]; 4]) -> Option<[f64; 4]> {
1877 let scale = rows
1878 .iter()
1879 .flat_map(|r| r[..4].iter())
1880 .fold(0.0_f64, |m, v| m.max(v.abs()));
1881 if scale <= f64::MIN_POSITIVE {
1882 return None;
1883 }
1884 for col in 0..4 {
1885 let pivot = (col..4).max_by(|&i, &j| rows[i][col].abs().total_cmp(&rows[j][col].abs()))?;
1886 if rows[pivot][col].abs() <= 1e-14 * scale {
1887 return None;
1888 }
1889 rows.swap(col, pivot);
1890 let lead = rows[col];
1891 for row in rows.iter_mut().skip(col + 1) {
1892 let f = row[col] / lead[col];
1893 for (v, l) in row.iter_mut().zip(lead).skip(col) {
1894 *v -= f * l;
1895 }
1896 }
1897 }
1898 let mut out = [0.0; 4];
1899 for r in (0..4).rev() {
1900 let mut v = rows[r][4];
1901 for (c, known) in out.iter().enumerate().skip(r + 1) {
1902 v -= rows[r][c] * known;
1903 }
1904 out[r] = v / rows[r][r];
1905 }
1906 Some(out)
1907}
1908
1909fn retrimmed(
1913 curve: &Curve,
1914 range: (f64, f64),
1915 ends: [Option<Point>; 2],
1916 tol: Tolerances,
1917) -> OgeomResult<(Curve, (f64, f64))> {
1918 let mut new = [range.0, range.1];
1919 for (k, end) in ends.iter().enumerate() {
1920 let Some(p) = end else {
1921 continue;
1922 };
1923 let t = match curve {
1924 Curve::Line(line) => {
1925 let axis = line.axis();
1926 (*p - axis.location).dot(axis.direction.vector())
1927 }
1928 _ => {
1929 let mut t = new[k];
1930 for _ in 0..64 {
1931 let c = curve.point_at(t, tol)?;
1932 let d = curve.d1_at(t, tol)?;
1933 let speed = d.dot(d);
1934 if speed <= f64::MIN_POSITIVE {
1935 break;
1936 }
1937 let step = (*p - c).dot(d) / speed;
1938 t += step;
1939 if step.abs() <= 1e-15 * (1.0 + t.abs()) {
1940 break;
1941 }
1942 }
1943 t
1944 }
1945 };
1946 let reached = match curve {
1947 Curve::Line(line) => line.axis().point_at(t),
1948 _ => curve.point_at(t, tol)?,
1949 };
1950 if reached.distance(*p) > tol.confusion() * 10.0 {
1951 ogeom_bail!(
1952 Construction,
1953 "a border ending at a crease does not reach the crease's mitre \
1954 ({:.3e} away); a crease is joined where the borders at its ends \
1955 lie square to it",
1956 reached.distance(*p)
1957 );
1958 }
1959 new[k] = t;
1960 }
1961 if new[1] - new[0] <= tol.parametric() {
1962 ogeom_bail!(
1963 Construction,
1964 "the thickness reaches past a border's length where it meets a \
1965 crease; the faces' offsets cross beyond it"
1966 );
1967 }
1968 Ok(match curve {
1969 Curve::Line(line) => {
1970 let (d0, d1) = curve.domain();
1971 let carried = LineCurve::over(line.axis(), new[0].min(d0), new[1].max(d1))?;
1972 (carried.into(), (new[0], new[1]))
1973 }
1974 Curve::Circle(c) => {
1977 let circle = c.circle();
1978 let start = curve.point_at(new[0], tol)?;
1979 let frame = Frame::new(
1980 circle.centre(),
1981 circle.frame().z(),
1982 Direction::new(start - circle.centre(), tol)?,
1983 tol,
1984 )?;
1985 let restarted = ogeom_math::Circle::new(frame, circle.radius(), tol)?;
1986 (
1987 ogeom_geom::CircleCurve::new(restarted).into(),
1988 (0.0, new[1] - new[0]),
1989 )
1990 }
1991 _ => (curve.clone(), (new[0], new[1])),
1992 })
1993}
1994
1995fn on_branch(
1998 image: PlanarCurve,
1999 t: f64,
2000 near: Point2,
2001 surface: &SurfaceGeometry,
2002 tol: Tolerances,
2003) -> OgeomResult<PlanarCurve> {
2004 let ((ua, ub), (va, vb)) = surface.domain();
2005 let at = image.point_at(t, tol)?;
2006 let whole = |periodic: bool, period: f64, gap: f64| {
2007 if periodic && period > 0.0 {
2008 (gap / period).round() * period
2009 } else {
2010 0.0
2011 }
2012 };
2013 let shift = Vector2::new(
2014 whole(surface.is_periodic_u(), ub - ua, near.x - at.x),
2015 whole(surface.is_periodic_v(), vb - va, near.y - at.y),
2016 );
2017 if shift.x == 0.0 && shift.y == 0.0 {
2018 return Ok(image);
2019 }
2020 image.transformed(&Transform2::translation(shift), tol)
2021}
2022
2023fn riser(
2026 model: &mut Model,
2027 vertex: TShapeId,
2028 (lower, upper): (&Layer, &Layer),
2029 risers: &mut FastMap<TShapeId, Shape>,
2030 tol: Tolerances,
2031) -> OgeomResult<Shape> {
2032 if let Some(found) = risers.get(&vertex) {
2033 return Ok(found.clone());
2034 }
2035 let (from, a) = lower.vertices[&vertex].clone();
2036 let (to, b) = upper.vertices[&vertex].clone();
2037 let segment = LineCurve::segment(a, b, tol)?;
2038 let span = a.distance(b);
2039 let shape = make_edge_between(model, segment.into(), (0.0, span), &from, &to, tol)?.shape;
2040 risers.insert(vertex, shape.clone());
2041 Ok(shape)
2042}
2043
2044fn outward_of(sheet: &Sheet, ei: usize, tol: Tolerances) -> OgeomResult<Vector> {
2047 let edge = &sheet.edges[ei];
2048 let used = &edge.uses[0];
2049 let face = &sheet.faces[used.face];
2050 let Some((curve, range)) = &edge.curve else {
2051 ogeom_bail!(Construction, "a free edge has no curve in space");
2052 };
2053 let mid = f64::midpoint(range.0, range.1);
2054 let uv = seat_on(
2055 sheet,
2056 edge,
2057 used,
2058 (curve.point_at(mid, tol)?, mid, *range),
2059 tol,
2060 )?;
2061 let natural = face.surface.normal_at(uv.x, uv.y, tol)?.vector();
2062 let walked = if used.sense == Orientation::Reversed {
2063 -curve.d1_at(mid, tol)?
2064 } else {
2065 curve.d1_at(mid, tol)?
2066 };
2067 Ok(walked.cross(natural))
2068}
2069
2070fn edge_samples(
2072 model: &Model,
2073 edge: &Shape,
2074 count: i32,
2075 tol: Tolerances,
2076) -> OgeomResult<Vec<Point>> {
2077 let Some(EdgeRepr::Curve3d { curve, range, .. }) = model
2078 .node(edge)
2079 .and_then(|n| n.data().as_edge())
2080 .and_then(EdgeData::curve3d)
2081 else {
2082 ogeom_bail!(Construction, "a layer edge has no curve");
2083 };
2084 let Some(curve) = model.geometry().curve(*curve) else {
2085 ogeom_bail!(Dangling, "curve is not in this model");
2086 };
2087 (0..=count)
2088 .map(|k| {
2089 curve.point_at(
2090 range.0 + (range.1 - range.0) * f64::from(k) / f64::from(count),
2091 tol,
2092 )
2093 })
2094 .collect()
2095}
2096
2097fn flat_side_face(
2101 model: &mut Model,
2102 sheet: &Sheet,
2103 ei: usize,
2104 (lower, upper): (&Layer, &Layer),
2105 risers: &mut FastMap<TShapeId, Shape>,
2106 tol: Tolerances,
2107) -> OgeomResult<Shape> {
2108 let edge = &sheet.edges[ei];
2109 let rise_start = riser(model, edge.ends.0, (lower, upper), risers, tol)?;
2110 let rise_end = riser(model, edge.ends.1, (lower, upper), risers, tol)?;
2111 let low = lower.edges[&edge.node].clone();
2112 let high = upper.edges[&edge.node].clone();
2113 let mut walk = edge_samples(model, &low, 4 * PROBES, tol)?;
2117 let mut back = edge_samples(model, &high, 4 * PROBES, tol)?;
2118 back.reverse();
2119 walk.extend(back);
2120 let mut turning = Vector::ZERO;
2121 for (k, p) in walk.iter().enumerate() {
2122 let q = walk[(k + 1) % walk.len()];
2123 turning += Vector::new(
2124 (p.y - q.y) * (p.z + q.z),
2125 (p.z - q.z) * (p.x + q.x),
2126 (p.x - q.x) * (p.y + q.y),
2127 );
2128 }
2129 #[allow(clippy::cast_precision_loss, reason = "a few dozen samples")]
2130 let count = walk.len() as f64;
2131 let centre = walk
2132 .iter()
2133 .fold(Point::ORIGIN, |acc, p| acc + (*p - Point::ORIGIN) / count);
2134 let normal = Direction::new(turning, tol)?;
2135 let flat = walk
2136 .iter()
2137 .map(|p| (*p - centre).dot(normal.vector()).abs())
2138 .fold(0.0_f64, f64::max);
2139 if flat > tol.confusion() * 10.0 {
2140 ogeom_bail!(
2141 Construction,
2142 "the side along a border ending at a crease is not flat ({flat:.3e} \
2143 out of its plane); a crease is joined where the borders at its ends \
2144 and their offsets lie in one plane"
2145 );
2146 }
2147 let plane =
2148 ogeom_math::Plane::new(Frame::new(centre, normal, normal.any_perpendicular(), tol)?);
2149 let bare = make_face_with_pcurves(
2150 model,
2151 PlaneSurface::new(plane).into(),
2152 &[vec![
2153 low.clone(),
2154 rise_end.clone(),
2155 high.reversed(),
2156 rise_start.reversed(),
2157 ]],
2158 tol,
2159 )?
2160 .shape;
2161 for e in [&low, &high, &rise_start, &rise_end] {
2162 same_parameter(model, e, tol)?;
2163 }
2164 Ok(if normal.vector().dot(outward_of(sheet, ei, tol)?) >= 0.0 {
2165 bare
2166 } else {
2167 bare.reversed()
2168 })
2169}
2170
2171#[allow(
2175 clippy::too_many_lines,
2176 reason = "one construction: chart, edges, pcurves, orientation"
2177)]
2178fn side_face(
2179 model: &mut Model,
2180 sheet: &Sheet,
2181 ei: usize,
2182 (lo, hi): (f64, f64),
2183 (lower, upper): (&Layer, &Layer),
2184 risers: &mut FastMap<TShapeId, Shape>,
2185 tol: Tolerances,
2186) -> OgeomResult<Shape> {
2187 let target = tol.approximation();
2188 let edge = &sheet.edges[ei];
2189 let used = &edge.uses[0];
2190 let face = &sheet.faces[used.face];
2191 let bound = fit_bound(face.tolerance, hi - lo, tol);
2192 let Some((curve, range)) = edge.curve.clone() else {
2193 ogeom_bail!(Construction, "a free edge has no curve in space");
2194 };
2195 let (t0, t1) = range;
2196 let height = hi - lo;
2197 let normal_at = |t: f64| -> OgeomResult<(Point, Vector)> {
2199 let at = curve.point_at(t, tol)?;
2200 let uv = seat_on(sheet, edge, used, (at, t, range), tol)?;
2201 let Some(n) = sheet_normal(face, uv, tol) else {
2202 ogeom_bail!(
2203 Construction,
2204 "a free edge runs into a point where its face has no \
2205 normal; the side there is not determined"
2206 );
2207 };
2208 Ok((at, n))
2209 };
2210 let ruled = |t: f64, s: f64| -> OgeomResult<Point> {
2211 let (at, n) = normal_at(t)?;
2212 Ok(at + n * (lo + s))
2213 };
2214
2215 let measure = |surface: &SurfaceGeometry, v0: f64, sense: f64| -> OgeomResult<f64> {
2218 let mut worst = 0.0_f64;
2219 for i in 0..=4 * PROBES {
2220 let t = t0 + (t1 - t0) * f64::from(i) / f64::from(4 * PROBES);
2221 for s in [0.0, 0.5 * height, height] {
2222 let p = surface.point_at(t, sense.mul_add(s, v0), tol)?;
2223 worst = worst.max(p.distance(ruled(t, s)?));
2224 }
2225 }
2226 Ok(worst)
2227 };
2228 let mut chart: Option<(SurfaceGeometry, f64, f64, f64)> = None;
2229 let (_, n0) = normal_at(t0)?;
2230 match &curve {
2231 Curve::Line(line) => {
2232 let axis = line.axis();
2233 let across = Direction::new(axis.direction.vector().cross(n0), tol)?;
2234 let frame = Frame::new(axis.location + n0 * lo, across, axis.direction, tol)?;
2235 let pad = (t1 - t0).abs() + height;
2236 let plane: SurfaceGeometry = PlaneSurface::over(
2237 ogeom_math::Plane::new(frame),
2238 (t0 - pad, t1 + pad),
2239 (-pad, height + pad),
2240 )?
2241 .into();
2242 if measure(&plane, 0.0, 1.0)? <= tol.confusion() * 10.0 {
2243 chart = Some((plane, 0.0, 1.0, 0.0));
2244 }
2245 }
2246 Curve::Circle(c) => {
2247 let circle = c.circle();
2248 let axis = circle.frame().z().vector();
2249 let sense = if n0.dot(axis) >= 0.0 { 1.0 } else { -1.0 };
2250 let (a, b) = (sense * lo, sense * hi);
2251 let cylinder: SurfaceGeometry = CylinderSurface::new(
2252 Cylinder::new(circle.frame(), circle.radius(), tol)?,
2253 (a.min(b) - height, a.max(b) + height),
2254 )?
2255 .into();
2256 if measure(&cylinder, sense * lo, sense)? <= tol.confusion() * 10.0 {
2257 chart = Some((cylinder, sense * lo, sense, 0.0));
2258 }
2259 }
2260 _ => {}
2261 }
2262 let (surface, v0, sense, deviation) = match chart {
2263 Some(found) => found,
2264 None => {
2265 let mut spans: u32 = 16;
2266 let mut best: Option<(SurfaceGeometry, f64)> = None;
2267 loop {
2268 let at = |k: u32| t0 + (t1 - t0) * f64::from(k) / f64::from(2 * spans);
2269 let us: Vec<f64> = (0..=spans).map(|k| at(2 * k)).collect();
2270 let rows: Vec<Vec<Point>> = [0.0, height]
2271 .iter()
2272 .map(|s| us.iter().map(|t| ruled(*t, *s)).collect())
2273 .collect::<OgeomResult<_>>()?;
2274 let fitted = ogeom_geom::fit::fit_surface_grid_at(
2275 &us,
2276 &[0.0, height],
2277 &rows,
2278 3,
2279 target * 0.5,
2280 tol,
2281 )?;
2282 let fit: SurfaceGeometry = fitted.curve.into();
2283 let worst = fitted.error.max(measure(&fit, 0.0, 1.0)?);
2284 if worst <= target {
2285 break (fit, 0.0, 1.0, worst);
2286 }
2287 if best.as_ref().is_none_or(|(_, b)| worst < *b) {
2288 best = Some((fit, worst));
2289 }
2290 if spans >= 1024 {
2291 match best {
2292 Some((closest, reached)) if reached <= bound => {
2293 break (closest, 0.0, 1.0, reached);
2294 }
2295 Some((_, reached)) => ogeom_bail!(
2296 NotDone,
2297 "the side face along a free edge did not fit within \
2298 {bound}; the closest fit was {reached} off"
2299 ),
2300 None => ogeom_bail!(NotDone, "the side face was not fitted"),
2301 }
2302 }
2303 spans *= 2;
2304 }
2305 }
2306 };
2307 let side_id = model.geometry_mut().add_surface(surface.clone());
2308
2309 let rise_start = riser(model, edge.ends.0, (lower, upper), risers, tol)?;
2311 let rise_end = riser(model, edge.ends.1, (lower, upper), risers, tol)?;
2312 let low = lower.edges[&edge.node].clone();
2313 let high = upper.edges[&edge.node].clone();
2314
2315 let forward = Orientation::Forward;
2320 let backward = Orientation::Reversed;
2321 let walk: Vec<(Shape, Orientation)> = if sense > 0.0 {
2322 vec![
2323 (low.clone(), forward),
2324 (rise_end.clone(), forward),
2325 (high.clone(), backward),
2326 (rise_start.clone(), backward),
2327 ]
2328 } else {
2329 vec![
2330 (rise_start.clone(), forward),
2331 (high.clone(), forward),
2332 (rise_end.clone(), backward),
2333 (low.clone(), backward),
2334 ]
2335 };
2336 let ring: Vec<Shape> = walk.iter().map(|(e, o)| e.oriented(*o)).collect();
2337 let wire = make_wire(model, &ring, tol)?.shape;
2338 let bare = make_face_on(model, side_id, std::slice::from_ref(&wire), tol)?.shape;
2339
2340 let row = |v: f64| -> OgeomResult<PlanarCurve> {
2341 Ok(Line2d::over(Axis2::new(Point2::new(0.0, v), Direction2::X), t0, t1)?.into())
2342 };
2343 let up = Direction2::new(ogeom_math::Vector2::new(0.0, sense), tol)?;
2344 let column = |t: f64, length: f64| -> OgeomResult<PlanarCurve> {
2345 Ok(Line2d::over(Axis2::new(Point2::new(t, v0), up), 0.0, length)?.into())
2346 };
2347 let identity = Location::identity();
2348 attach_pcurve(model, &low, row(v0)?, side_id, identity.clone(), range)?;
2349 attach_pcurve(
2350 model,
2351 &high,
2352 row(sense.mul_add(height, v0))?,
2353 side_id,
2354 identity.clone(),
2355 range,
2356 )?;
2357 let length_of = |model: &Model, e: &Shape| -> OgeomResult<f64> {
2358 match model
2359 .node(e)
2360 .and_then(|n| n.data().as_edge())
2361 .and_then(EdgeData::curve3d)
2362 {
2363 Some(EdgeRepr::Curve3d { range, .. }) => Ok(range.1),
2364 _ => ogeom_bail!(Construction, "a riser has no curve"),
2365 }
2366 };
2367 if rise_start.node() == rise_end.node() {
2368 let length = length_of(model, &rise_start)?;
2371 let (climbed, descended) = if sense > 0.0 { (t1, t0) } else { (t0, t1) };
2372 attach_seam(
2373 model,
2374 &rise_start,
2375 column(climbed, length)?,
2376 column(descended, length)?,
2377 side_id,
2378 identity,
2379 (0.0, length),
2380 )?;
2381 } else {
2382 let length = length_of(model, &rise_start)?;
2383 attach_pcurve(
2384 model,
2385 &rise_start,
2386 column(t0, length)?,
2387 side_id,
2388 identity.clone(),
2389 (0.0, length),
2390 )?;
2391 let length = length_of(model, &rise_end)?;
2392 attach_pcurve(
2393 model,
2394 &rise_end,
2395 column(t1, length)?,
2396 side_id,
2397 identity,
2398 (0.0, length),
2399 )?;
2400 }
2401 for e in [&low, &high, &rise_start, &rise_end] {
2402 same_parameter(model, e, tol)?;
2403 }
2404 if deviation > 0.0 {
2405 model.widen(&bare, Tolerance::new(deviation + tol.confusion())?)?;
2406 }
2407
2408 let mid = f64::midpoint(t0, t1);
2409 let outward = outward_of(sheet, ei, tol)?;
2410 let side_normal = surface
2411 .normal_at(mid, sense.mul_add(0.5 * height, v0), tol)?
2412 .vector();
2413 Ok(if side_normal.dot(outward) >= 0.0 {
2414 bare
2415 } else {
2416 bare.reversed()
2417 })
2418}
2419
2420fn state_new_gaps(
2424 model: &mut Model,
2425 given: &Shape,
2426 built: &Shape,
2427 tol: Tolerances,
2428) -> OgeomResult<()> {
2429 let held: ogeom_core::FastSet<_> = ogeom_topo::explore_unique(model, given, ShapeType::Edge)?
2430 .iter()
2431 .map(Shape::node)
2432 .collect();
2433 let made: Vec<Shape> = ogeom_topo::explore_unique(model, built, ShapeType::Edge)?
2434 .into_iter()
2435 .filter(|e| !held.contains(&e.node()))
2436 .collect();
2437 ogeom_algo::state_pcurve_gaps_of(model, &made, tol)
2438}