use crate::wire2d::Join;
use ogeom_algo::{
Built, History, edge_vertices, make_edge, make_edge_between, make_face_with_pcurves,
make_revolution_band, make_solid, make_vertex, sew,
};
use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
use ogeom_geom::Curve3d as _;
use ogeom_geom::{Curve, CylinderSurface, LineCurve, PlaneSurface, SurfaceGeometry};
use ogeom_math::{Cylinder, Frame, Plane, Point, Vector};
use ogeom_topo::{
EdgeData, EdgeRepr, Filter, Model, NodeData, Orientation, Shape, ShapeType, TShapeId, explore,
explore_unique,
};
use std::collections::HashMap;
type Displacement<'a> = dyn Fn(&Model, usize, Point) -> OgeomResult<Option<(Vector, f64)>> + 'a;
pub(crate) fn canonical_input(
model: &mut Model,
solid: &Shape,
handles: &[Shape],
tol: Tolerances,
) -> OgeomResult<(Shape, Vec<Shape>, Option<ogeom_algo::History>)> {
let probe = Point::new(0.123_456_789, 9.87, -3.21);
let mut seen: HashMap<TShapeId, Point> = HashMap::new();
let mut instanced = false;
'outer: for kind in [ShapeType::Vertex, ShapeType::Edge] {
for occurrence in explore(model, solid, Filter::OfType(kind))? {
let at = occurrence.transform(model.datums())?.apply(probe);
match seen.entry(occurrence.node()) {
std::collections::hash_map::Entry::Occupied(held) => {
if held.get().distance(at) > tol.confusion() {
instanced = true;
break 'outer;
}
}
std::collections::hash_map::Entry::Vacant(slot) => {
slot.insert(at);
}
}
}
}
if !instanced {
return Ok((solid.clone(), handles.to_vec(), None));
}
let baked = ogeom_algo::baked_shape(model, solid, tol)?;
let mapped = handles
.iter()
.map(|h| match baked.history.trace(h) {
[one] => Ok(one.clone()),
traced => ogeom_bail!(
Construction,
"a face handle resolved to {} faces through the canonical \
rebuild; the reference is ambiguous",
traced.len()
),
})
.collect::<OgeomResult<Vec<Shape>>>()?;
Ok((baked.shape, mapped, Some(baked.history)))
}
pub fn offset_shape(
model: &mut Model,
solid: &Shape,
offset: f64,
tol: Tolerances,
) -> OgeomResult<Built> {
if !offset.is_finite() || offset.abs() <= tol.confusion() {
ogeom_bail!(Construction, "an offset of {offset} moves nothing");
}
let (canonical, _, prefix) = canonical_input(model, solid, &[], tol)?;
if let Some(prefix) = prefix {
let mut out = offset_shape(model, &canonical, offset, tol)?;
out.history = prefix.then(&out.history);
return Ok(out);
}
rebuilt(model, solid, &|_| offset, &|_| None, tol)
}
pub fn make_thick_solid(
model: &mut Model,
solid: &Shape,
removed: &[Shape],
thickness: f64,
tol: Tolerances,
) -> OgeomResult<Built> {
make_thick_solid_with(model, solid, removed, thickness, Join::Intersection, tol)
}
pub fn make_thick_solid_with(
model: &mut Model,
solid: &Shape,
removed: &[Shape],
thickness: f64,
join: Join,
tol: Tolerances,
) -> OgeomResult<Built> {
if !thickness.is_finite() || thickness.abs() <= tol.confusion() {
ogeom_bail!(Construction, "a wall of {thickness} holds nothing");
}
let (canonical, mapped, prefix) = canonical_input(model, solid, removed, tol)?;
if let Some(prefix) = prefix {
let mut out = make_thick_solid_with(model, &canonical, &mapped, thickness, join, tol)?;
out.history = prefix.then(&out.history);
return Ok(out);
}
let outward_walls = thickness < 0.0;
let reach = thickness.abs();
let own: Vec<TShapeId> = explore(model, solid, Filter::OfType(ShapeType::Face))?
.iter()
.map(Shape::node)
.collect();
for face in removed {
if !own.contains(&face.node()) {
ogeom_bail!(Construction, "a removed face is not a face of the solid");
}
}
let mut tangent_opening = false;
for face in removed {
if has_tangent_neighbour(model, solid, face, tol)? {
tangent_opening = true;
break;
}
}
if !tangent_opening {
let skip: Vec<TShapeId> = removed.iter().map(Shape::node).collect();
let moved = rebuilt(
model,
solid,
&|face| {
if skip.contains(&face.node()) {
0.0
} else if outward_walls {
reach
} else {
-reach
}
},
&|_| None,
tol,
)?;
let moved = if join == Join::Arc {
let held: Vec<TShapeId> = removed
.iter()
.flat_map(|f| {
moved
.history
.modified(f)
.iter()
.map(Shape::node)
.collect::<Vec<_>>()
})
.collect();
let edges = growing_edges(model, &moved.shape, &held, outward_walls, tol)?;
if edges.is_empty() {
moved
} else {
ogeom_fillet::fillet_edges(model, &moved.shape, &edges, reach, tol)?
}
} else {
moved
};
let mut result = if outward_walls {
ogeom_bool::cut(model, &moved.shape, solid, tol)?
} else {
ogeom_bool::cut(model, solid, &moved.shape, tol)?
};
for face in removed {
result.history.delete(face);
}
return Ok(result);
}
if join == Join::Arc {
ogeom_bail!(
Construction,
"the arc join is built where every opening meets its neighbours \
across a corner; an opening with a tangent neighbour is not yet"
);
}
let displaced = if outward_walls { reach } else { -reach };
let moved = rebuilt(model, solid, &|_| displaced, &|_| None, tol)?;
let opening_normal = |model: &Model, face: &Shape| -> OgeomResult<Vector> {
let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
ogeom_bail!(Construction, "face node holds no face data");
};
let Some(SurfaceGeometry::Plane(p)) = model.geometry().surface(data.surface) else {
ogeom_bail!(
Construction,
"a tangent opening must be planar; a curved opening needs \
the general rebuild; see docs/PARITY.md, offset.shell-thicken"
);
};
let mut normal = p.plane().normal().vector();
if face.orientation() == Orientation::Reversed {
normal = -normal;
}
Ok(normal)
};
let mut result = if outward_walls {
let mut tool = solid.clone();
for face in removed {
let outward = opening_normal(model, face)?;
let punch = ogeom_algo::make_prism(model, &face.clone(), outward * (2.0 * reach), tol)?;
tool = ogeom_bool::fuse(model, &tool, &punch.shape, tol)?.shape;
}
ogeom_bool::cut(model, &moved.shape, &tool, tol)?
} else {
let mut tool = moved.shape.clone();
for face in removed {
let outward = opening_normal(model, face)?;
let [image] = moved.history.modified(face) else {
ogeom_bail!(Construction, "a removed face has no single cavity image");
};
let punch =
ogeom_algo::make_prism(model, &image.clone(), outward * (2.0 * reach), tol)?;
tool = ogeom_bool::fuse(model, &tool, &punch.shape, tol)?.shape;
}
ogeom_bool::cut(model, solid, &tool, tol)?
};
for face in removed {
result.history.delete(face);
}
Ok(result)
}
fn has_tangent_neighbour(
model: &Model,
solid: &Shape,
face: &Shape,
tol: Tolerances,
) -> OgeomResult<bool> {
use ogeom_geom::Surface as _;
let own_edges: Vec<TShapeId> = explore(model, face, Filter::OfType(ShapeType::Edge))?
.iter()
.map(Shape::node)
.collect();
let normal_at = |model: &Model, face: &Shape, at: Point| -> OgeomResult<Option<Vector>> {
let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
ogeom_bail!(Construction, "face node holds no face data");
};
let Some(surface) = model.geometry().surface(data.surface) else {
ogeom_bail!(Dangling, "face refers to a surface not in this model");
};
let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
if projection.distance > tol.confusion() * 100.0 {
return Ok(None);
}
let (u, v) = projection.parameters;
let (du, dv) = surface.d1_at(u, v, tol)?;
let n = du.cross(dv);
let m = n.magnitude();
if m <= tol.confusion() {
return Ok(None);
}
Ok(Some(n / m))
};
for other in explore(model, solid, Filter::OfType(ShapeType::Face))? {
if other.node() == face.node() {
continue;
}
for edge in explore(model, &other, Filter::OfType(ShapeType::Edge))? {
if !own_edges.contains(&edge.node()) {
continue;
}
let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
continue;
};
let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
continue;
};
let Some(geometry) = model.geometry().curve(*curve) else {
continue;
};
let mid = geometry.point_at(f64::midpoint(range.0, range.1), tol)?;
let (Some(a), Some(b)) = (normal_at(model, face, mid)?, normal_at(model, &other, mid)?)
else {
continue;
};
if a.cross(b).magnitude() <= 1e-6 {
return Ok(true);
}
}
}
Ok(false)
}
struct Prepared {
shape: Shape,
surface: SurfaceGeometry,
amount: f64,
sign: f64,
rings: Option<[Shape; 2]>,
}
pub(crate) fn rebuilt(
model: &mut Model,
solid: &Shape,
amount_of: &dyn Fn(&Shape) -> f64,
instead_of: &dyn Fn(&Shape) -> Option<SurfaceGeometry>,
tol: Tolerances,
) -> OgeomResult<Built> {
use ogeom_geom::Surface as _;
let faces = explore(model, solid, Filter::OfType(ShapeType::Face))?;
let mut prepared: Vec<Prepared> = Vec::with_capacity(faces.len());
for face in &faces {
let amount = amount_of(face);
let Some(node) = model.node(face) else {
ogeom_bail!(Dangling, "face is not in this model");
};
let NodeData::Face(data) = node.data() else {
ogeom_bail!(Construction, "face node holds no face data");
};
let Some(surface) = model.geometry().surface(data.surface) else {
ogeom_bail!(Dangling, "face refers to a surface not in this model");
};
let sign = if face.orientation() == Orientation::Reversed {
-1.0
} else {
1.0
};
let edges = explore(model, face, Filter::OfType(ShapeType::Edge))?;
let mut counts: HashMap<TShapeId, usize> = HashMap::new();
for e in &edges {
*counts.entry(e.node()).or_insert(0) += 1;
}
let has_seam = counts.values().any(|c| *c >= 2);
let closed_rings: Vec<Shape> = edges
.iter()
.filter(|e| {
edge_vertices(model, e)
.ok()
.flatten()
.is_some_and(|(a, b)| a.node() == b.node())
})
.cloned()
.collect();
let grow = amount.abs() * 4.0 + 1.0;
let replacement = instead_of(face);
let moved: SurfaceGeometry = if let Some(given) = replacement {
given
} else if amount == 0.0 {
surface.clone()
} else {
match surface {
SurfaceGeometry::Plane(p) => {
let plane = p.plane();
let ((u0, u1), (v0, v1)) = surface.domain();
let shifted = Plane::new(Frame::new(
plane.origin() + plane.normal().vector() * (sign * amount),
plane.normal(),
plane.frame().x(),
tol,
)?);
PlaneSurface::over(shifted, (u0 - grow, u1 + grow), (v0 - grow, v1 + grow))?
.into()
}
SurfaceGeometry::Cylinder(c) => {
let cylinder = c.cylinder();
let grown = sign.mul_add(amount, cylinder.radius());
if grown <= tol.confusion() {
ogeom_bail!(Construction, "the offset consumes the cylinder's radius");
}
let (_, (v0, v1)) = surface.domain();
CylinderSurface::new(
Cylinder::new(cylinder.frame(), grown, tol)?,
(v0 - grow, v1 + grow),
)?
.into()
}
SurfaceGeometry::Sphere(sp) => {
let sphere = sp.sphere();
let grown = sign.mul_add(amount, sphere.radius());
if grown <= tol.confusion() {
ogeom_bail!(Construction, "the offset consumes the sphere's radius");
}
ogeom_geom::SphereSurface::new(ogeom_math::Sphere::centred(
sphere.centre(),
grown,
tol,
)?)
.into()
}
SurfaceGeometry::Torus(t) => {
let torus = t.torus();
let grown = sign.mul_add(amount, torus.minor_radius());
if grown <= tol.confusion() {
ogeom_bail!(Construction, "the offset consumes the torus's tube");
}
ogeom_geom::TorusSurface::new(ogeom_math::Torus::new(
torus.frame(),
torus.major_radius(),
grown,
tol,
)?)
.into()
}
SurfaceGeometry::Cone(co) => {
let cone = co.cone();
let grown = (sign * amount / cone.half_angle().cos())
.mul_add(1.0, cone.reference_radius());
if grown <= tol.confusion() {
ogeom_bail!(Construction, "the offset consumes the cone's throat");
}
let (_, (v0, v1)) = surface.domain();
ogeom_geom::ConeSurface::new(
ogeom_math::Cone::new(cone.frame(), grown, cone.half_angle(), tol)?,
(v0 - grow, v1 + grow),
)?
.into()
}
_ => ogeom_bail!(
Construction,
"offsetting a face on this surface needs a construction \
the rebuild does not yet speak; see docs/PARITY.md, offset.shell-thicken"
),
}
};
let fitted_support = matches!(moved, SurfaceGeometry::BSpline(_));
prepared.push(Prepared {
shape: face.clone(),
surface: moved,
amount,
sign,
rings: if has_seam && closed_rings.len() == 2 && !fitted_support {
Some([closed_rings[0].clone(), closed_rings[1].clone()])
} else {
None
},
});
}
let mut edge_faces: HashMap<TShapeId, Vec<usize>> = HashMap::new();
for (fi, face) in faces.iter().enumerate() {
for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
let entry = edge_faces.entry(e.node()).or_default();
if !entry.contains(&fi) {
entry.push(fi);
}
}
}
let constraint = |model: &Model, fi: usize, at: Point| -> OgeomResult<Option<(Vector, f64)>> {
let face = &faces[fi];
let Some(node) = model.node(face) else {
ogeom_bail!(Dangling, "face is not in this model");
};
let NodeData::Face(data) = node.data() else {
ogeom_bail!(Construction, "face node holds no face data");
};
let Some(surface) = model.geometry().surface(data.surface) else {
ogeom_bail!(Dangling, "face refers to a surface not in this model");
};
let projection = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
if projection.distance > tol.confusion() * 100.0 {
return Ok(None);
}
let (u, v) = projection.parameters;
let (du, dv) = surface.d1_at(u, v, tol)?;
let n = du.cross(dv);
let m = n.magnitude();
if m <= tol.confusion() {
return Ok(None);
}
let outward = n / m * prepared[fi].sign;
Ok(Some((outward, prepared[fi].amount)))
};
let mut new_vertices: HashMap<TShapeId, (Shape, Point)> = HashMap::new();
for vertex in explore_unique(model, solid, ShapeType::Vertex)? {
let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
continue;
};
let at = vertex.transform(model.datums())?.apply(data.point);
let mut seats: Vec<usize> = Vec::new();
for (fi, face) in faces.iter().enumerate() {
for v in explore(model, face, Filter::OfType(ShapeType::Vertex))? {
if v.node() == vertex.node() && !seats.contains(&fi) {
seats.push(fi);
}
}
}
if seats.is_empty() {
continue;
}
let mut normals: Vec<Vector> = Vec::new();
let mut amounts: Vec<f64> = Vec::new();
let mut kept: Vec<usize> = Vec::new();
for fi in &seats {
let Some((n, w)) = constraint(model, *fi, at)? else {
continue;
};
if let Some(k) = normals
.iter()
.position(|m| m.cross(n).magnitude() <= tol.angular().max(1e-6))
{
if (amounts[k] - w).abs() > tol.confusion() {
ogeom_bail!(
Construction,
"two tangent faces move a shared vertex by different \
amounts; the offset tears it"
);
}
continue;
}
normals.push(n);
amounts.push(w);
kept.push(*fi);
}
if normals.is_empty() {
let mut apex: Option<Point> = None;
for fi in &seats {
let Some(node) = model.node(&faces[*fi]) else {
continue;
};
let NodeData::Face(data) = node.data() else {
continue;
};
let Some(SurfaceGeometry::Cone(old)) = model.geometry().surface(data.surface)
else {
continue;
};
if old.cone().apex().distance(at) > tol.confusion() * 100.0 {
continue;
}
if let SurfaceGeometry::Cone(moved_cone) = &prepared[*fi].surface {
apex = Some(moved_cone.cone().apex());
break;
}
}
let Some(moved) = apex else {
ogeom_bail!(
Construction,
"a vertex with no seat the rebuild can read cannot be \
re-solved"
);
};
new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
continue;
}
if normals.len() == 1 {
let moved = at + normals[0] * amounts[0];
new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
continue;
}
if kept.len() == 2
&& let Some(moved) = seam_end(model, &faces, &prepared, &vertex, &kept, at, tol)?
{
new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
continue;
}
let mut moved = at + solve_corner(&normals, &amounts, tol)?;
for _ in 0..8 {
let mut ns: Vec<Vector> = Vec::new();
let mut rs: Vec<f64> = Vec::new();
for fi in &kept {
let projection =
ogeom_algo::project_on_surface(&prepared[*fi].surface, moved, 32, tol)?;
let (u, v) = projection.parameters;
let (du, dv) = prepared[*fi].surface.d1_at(u, v, tol)?;
let n = du.cross(dv);
let m = n.magnitude();
if m <= tol.confusion() {
continue;
}
let n = n / m;
let foot = prepared[*fi].surface.point_at(u, v, tol)?;
ns.push(n);
rs.push((moved - foot).dot(n));
}
if ns.len() < 2 {
break;
}
let worst = rs.iter().fold(0.0_f64, |a, r| a.max(r.abs()));
if worst <= tol.confusion() * 0.1 {
break;
}
let step: Vec<f64> = rs.iter().map(|r| -r).collect();
moved += solve_corner(&ns, &step, tol)?;
}
new_vertices.insert(vertex.node(), (make_vertex(model, moved).shape, moved));
}
let mut edge_uses: HashMap<TShapeId, usize> = HashMap::new();
for face in &faces {
for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
*edge_uses.entry(e.node()).or_insert(0) += 1;
}
}
let mut new_edges: HashMap<TShapeId, Shape> = HashMap::new();
let mut history = History::new();
for edge in explore_unique(model, solid, ShapeType::Edge)? {
let sides = edge_faces.get(&edge.node()).cloned().unwrap_or_default();
if sides.len() != 2 {
if edge_uses.get(&edge.node()).copied().unwrap_or(0) >= 2 {
if let [fi] = sides.as_slice()
&& let Some(built) =
rebuilt_seam_edge(model, &edge, &prepared[*fi], &new_vertices, tol)?
{
history.modify(&edge, built.clone());
new_edges.insert(edge.node(), built);
}
continue;
}
let Some(built) =
rebuilt_lone_edge(model, &edge, &sides, &constraint, &new_vertices, tol)?
else {
ogeom_bail!(
Construction,
"an edge with one face is neither a ring nor an apex; \
the offset cannot re-derive it"
);
};
history.modify(&edge, built.clone());
new_edges.insert(edge.node(), built);
continue;
}
let (curve, range) = {
let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
ogeom_bail!(Construction, "edge node holds no edge data");
};
let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
ogeom_bail!(Construction, "an edge has no curve to offset");
};
let Some(geometry) = model.geometry().curve(*curve) else {
ogeom_bail!(Dangling, "curve is not in this model");
};
(geometry.clone(), *range)
};
let forward = if edge.orientation() == Orientation::Reversed {
edge.reversed()
} else {
edge.clone()
};
let built = match &curve {
Curve::Line(_) => {
let Some((sv, ev)) = edge_vertices(model, &forward)? else {
ogeom_bail!(Construction, "a straight edge has no vertices");
};
let (Some((v_from, p_from)), Some((v_to, p_to))) = (
new_vertices.get(&sv.node()).cloned(),
new_vertices.get(&ev.node()).cloned(),
) else {
ogeom_bail!(Construction, "an edge end has no re-solved vertex");
};
if p_to.distance(p_from) <= tol.parametric() {
ogeom_bail!(Construction, "the offset collapses an edge");
}
let segment = LineCurve::segment(p_from, p_to, tol)?;
let (t0, t1) = segment.domain();
let moved: Curve = segment.into();
make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
}
Curve::Circle(c)
if !matches!(prepared[sides[0]].surface, SurfaceGeometry::BSpline(_))
&& !matches!(prepared[sides[1]].surface, SurfaceGeometry::BSpline(_)) =>
{
let circle = c.circle();
let found = ogeom_intersect::intersect_surfaces(
&prepared[sides[0]].surface,
&prepared[sides[1]].surface,
ogeom_intersect::IntersectOptions::default(),
tol,
)?;
let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
ogeom_bail!(
Construction,
"the moved faces no longer meet along the edge they \
shared; the offset collapses it"
);
};
let mut best: Option<(ogeom_math::Circle, f64)> = None;
for section in &candidates {
let Curve::Circle(cc) = §ion.curve else {
continue;
};
let candidate = cc.circle();
let score = candidate.centre().distance(circle.centre())
+ (candidate.radius() - circle.radius()).abs();
if best.as_ref().is_none_or(|(_, held)| score < *held) {
best = Some((candidate, score));
}
}
let Some((candidate, _)) = best else {
ogeom_bail!(
Construction,
"the moved faces meet along nothing circular where a \
circle was; the offset needs the general rebuild"
);
};
let reframed = ogeom_math::Circle::new(
Frame::new(
candidate.centre(),
circle.frame().z(),
circle.frame().x(),
tol,
)?,
candidate.radius(),
tol,
)?;
let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
let closed = {
let Some((sv, ev)) = edge_vertices(model, &forward)? else {
ogeom_bail!(Construction, "a ring has no vertex");
};
sv.node() == ev.node()
};
if closed {
make_edge(model, moved, range, tol)?.shape
} else {
let Some((sv, ev)) = edge_vertices(model, &forward)? else {
ogeom_bail!(Construction, "an arc has no vertices");
};
let (Some((v_from, p_from)), Some((v_to, p_to))) = (
new_vertices.get(&sv.node()).cloned(),
new_vertices.get(&ev.node()).cloned(),
) else {
ogeom_bail!(Construction, "an arc end has no re-solved vertex");
};
let angle_of = |p: Point| {
let l = reframed.frame().to_local(p);
l.y.atan2(l.x)
};
let tau = core::f64::consts::TAU;
let mut t0 = angle_of(p_from);
let mut t1 = angle_of(p_to);
while t0 < range.0 - core::f64::consts::PI {
t0 += tau;
}
while t0 > range.0 + core::f64::consts::PI {
t0 -= tau;
}
while t1 <= t0 + tol.parametric() {
t1 += tau;
}
if (t1 - t0) - (range.1 - range.0) > core::f64::consts::PI {
t1 -= tau;
}
if t1 <= t0 + tol.parametric() {
ogeom_bail!(Construction, "the offset collapses an arc");
}
make_edge_between(model, moved, (t0, t1), &v_from, &v_to, tol)?.shape
}
}
_ => {
let unmoved = {
let mut worst = 0.0_f64;
'probe: for i in 0..9 {
#[allow(clippy::cast_precision_loss)]
let t = range.0 + (range.1 - range.0) * (i as f64) / 8.0;
let p = curve.point_at(t, tol)?;
for side in [sides[0], sides[1]] {
let Ok(near) =
ogeom_algo::project_on_surface(&prepared[side].surface, p, 17, tol)
else {
worst = f64::INFINITY;
break 'probe;
};
worst = worst.max(near.distance);
}
}
(worst <= (tol.confusion() * 1e3).max(1e-4)).then_some(worst)
};
if let Some(worst) = unmoved {
let Some((sv, ev)) = edge_vertices(model, &forward)? else {
ogeom_bail!(Construction, "an edge has no vertices");
};
let closed = sv.node() == ev.node();
let built = if closed {
match new_vertices.get(&sv.node()).cloned() {
Some((v_at, p_at)) => {
let gap = curve.point_at(range.0, tol)?.distance(p_at);
if gap > tol.confusion() {
model.widen(&v_at, ogeom_core::Tolerance::new(gap * 2.0)?)?;
}
make_edge_between(model, curve.clone(), range, &v_at, &v_at, tol)?
.shape
}
None => make_edge(model, curve.clone(), range, tol)?.shape,
}
} else {
let (Some((v_from, p_from)), Some((v_to, p_to))) = (
new_vertices.get(&sv.node()).cloned(),
new_vertices.get(&ev.node()).cloned(),
) else {
ogeom_bail!(Construction, "an edge end has no re-solved vertex");
};
let gap = curve
.point_at(range.0, tol)?
.distance(p_from)
.min(curve.point_at(range.0, tol)?.distance(p_to))
.max(
curve
.point_at(range.1, tol)?
.distance(p_to)
.min(curve.point_at(range.1, tol)?.distance(p_from)),
);
if gap > tol.confusion() {
for v in [&v_from, &v_to] {
model.widen(v, ogeom_core::Tolerance::new(gap * 2.0)?)?;
}
}
make_edge_between(model, curve.clone(), range, &v_from, &v_to, tol)?.shape
};
if worst > tol.confusion()
&& let Some(node) = model.node_mut(&built)
&& let ogeom_topo::NodeData::Edge(data) = node.data_mut()
{
data.tolerance = data.tolerance.widen_to(worst);
}
history.modify(&edge, built.clone());
new_edges.insert(edge.node(), built);
continue;
}
let mid = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
let found = ogeom_intersect::intersect_surfaces(
&prepared[sides[0]].surface,
&prepared[sides[1]].surface,
ogeom_intersect::IntersectOptions::default(),
tol,
)?;
let ogeom_intersect::SurfaceIntersection::Along(candidates) = found else {
ogeom_bail!(
Construction,
"the moved faces no longer meet along the edge they \
shared; the offset collapses it"
);
};
let mut best: Option<(Curve, f64, f64)> = None;
for section in candidates {
let Ok(projected) = ogeom_algo::project_on_curve(§ion.curve, mid, 64, tol)
else {
continue;
};
if best
.as_ref()
.is_none_or(|(_, _, held)| projected.distance < *held)
{
best = Some((section.curve, section.tolerance, projected.distance));
}
}
let Some((moved, slop, _)) = best else {
ogeom_bail!(
Construction,
"the moved faces meet along nothing where the edge \
was; the offset collapses it"
);
};
let closed = {
let Some((sv, ev)) = edge_vertices(model, &forward)? else {
ogeom_bail!(Construction, "an edge has no vertices");
};
sv.node() == ev.node()
};
let built = if closed {
let Some((sv, _)) = edge_vertices(model, &forward)? else {
ogeom_bail!(Construction, "a ring has no vertex");
};
match (new_vertices.get(&sv.node()).cloned(), &moved) {
(Some((v_at, p_at)), Curve::BSpline(spline)) => {
let t = ogeom_algo::project_on_curve(&moved, p_at, 64, tol)?;
let (lo, hi) = moved.domain();
let seamed: Curve = if t.parameter > lo + tol.parametric()
&& t.parameter < hi - tol.parametric()
{
Curve::BSpline(spline.reseamed_at(t.parameter, tol)?)
} else {
moved.clone()
};
let seamed = {
use ogeom_geom::Reversible as _;
let (a, _) = seamed.domain();
let old = curve.d1_at(range.0, tol)?;
if seamed.d1_at(a, tol)?.dot(old) < 0.0 {
seamed.reversed()
} else {
seamed
}
};
let miss = t.distance.max(slop);
if miss > tol.confusion() {
model.widen(&v_at, ogeom_core::Tolerance::new(miss * 2.0)?)?;
}
let window = seamed.domain();
make_edge_between(model, seamed, window, &v_at, &v_at, tol)?.shape
}
_ => {
let window = moved.domain();
make_edge(model, moved, window, tol)?.shape
}
}
} else {
let Some((sv, ev)) = edge_vertices(model, &forward)? else {
ogeom_bail!(Construction, "an edge has no vertices");
};
let (Some((v_from, p_from)), Some((v_to, p_to))) = (
new_vertices.get(&sv.node()).cloned(),
new_vertices.get(&ev.node()).cloned(),
) else {
ogeom_bail!(Construction, "an edge end has no re-solved vertex");
};
if slop > tol.confusion() {
for v in [&v_from, &v_to] {
model.widen(v, ogeom_core::Tolerance::new(slop * 2.0)?)?;
}
}
let ta = ogeom_algo::project_on_curve(&moved, p_from, 64, tol)?.parameter;
let tb = ogeom_algo::project_on_curve(&moved, p_to, 64, tol)?.parameter;
if (tb - ta).abs() <= tol.parametric() {
ogeom_bail!(Construction, "the offset collapses an edge");
}
let (moved, ta, tb) = if ta <= tb {
(moved, ta, tb)
} else {
use ogeom_geom::Reversible as _;
let (lo, hi) = moved.domain();
(moved.reversed(), lo + hi - ta, lo + hi - tb)
};
make_edge_between(model, moved, (ta, tb), &v_from, &v_to, tol)?.shape
};
if slop > tol.confusion()
&& let Some(node) = model.node_mut(&built)
&& let ogeom_topo::NodeData::Edge(data) = node.data_mut()
{
data.tolerance = data.tolerance.widen_to(slop);
}
built
}
};
history.modify(&edge, built.clone());
new_edges.insert(edge.node(), built);
}
let mut rebuilt_faces: Vec<Shape> = Vec::with_capacity(prepared.len());
for prep in &prepared {
let built = if let Some(rings) = &prep.rings {
let (Some(lo), Some(hi)) = (
new_edges.get(&rings[0].node()),
new_edges.get(&rings[1].node()),
) else {
ogeom_bail!(Construction, "a band's ring was not rebuilt");
};
let band = make_revolution_band(model, &prep.surface, lo, hi, tol)?;
if prep.shape.orientation() == Orientation::Reversed {
band.reversed()
} else {
band
}
} else {
let mut wires: Vec<Vec<Shape>> = Vec::new();
let mut face_uses: HashMap<TShapeId, usize> = HashMap::new();
for wire in explore(model, &prep.shape, Filter::OfType(ShapeType::Wire))? {
let mut edges: Vec<Shape> = Vec::new();
for used in model.ordered_children_of(&wire)? {
*face_uses.entry(used.node()).or_insert(0) += 1;
let Some(fresh) = new_edges.get(&used.node()) else {
ogeom_bail!(Construction, "a face edge was not rebuilt");
};
edges.push(if used.orientation() == Orientation::Reversed {
fresh.reversed()
} else {
fresh.clone()
});
}
wires.push(edges);
}
let face = if face_uses.values().any(|c| *c >= 2) {
assembled_with_seam(model, prep, &wires, &new_edges, tol)?
} else {
make_face_with_pcurves(model, prep.surface.clone(), &wires, tol)?.shape
};
if prep.shape.orientation() == Orientation::Reversed {
face.reversed()
} else {
face
}
};
history.modify(&prep.shape, built.clone());
rebuilt_faces.push(built);
}
let sewn = sew(model, &rebuilt_faces, tol)?;
if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
ogeom_bail!(Construction, "the offset solid did not close");
}
let outer = {
let old_reversed = model
.children_of(solid)?
.first()
.is_some_and(|s| s.orientation() == Orientation::Reversed);
if old_reversed {
sewn.shells[0].reversed()
} else {
sewn.shells[0].clone()
}
};
let built = make_solid(model, std::slice::from_ref(&outer))?;
let size = ogeom_algo::shape_bounds(model, &built.shape, tol)?.diagonal();
let fine = (size * 1e-5).clamp(
tol.confusion() * 1e2,
ogeom_mesh::Deflection::default().chord,
);
let mut mass = None;
let mut first_error = None;
for chord in [ogeom_mesh::Deflection::default().chord, fine] {
let deflection = ogeom_mesh::Deflection {
chord,
..ogeom_mesh::Deflection::default()
};
match ogeom_algo::volume_properties(model, &built.shape, deflection, tol) {
Ok(props) => {
mass = Some(props.mass);
break;
}
Err(e @ (ogeom_core::OgeomError::Cancelled | ogeom_core::OgeomError::Dangling(_))) => {
return Err(e);
}
Err(e) => {
first_error.get_or_insert(e);
}
}
}
let Some(mass) = mass else {
ogeom_bail!(
Construction,
"the offset solid's mesh does not close at any tried resolution{}",
first_error.map_or_else(String::new, |e| format!(": {e}"))
);
};
if !mass.is_finite() || mass <= tol.confusion() {
ogeom_bail!(Construction, "the offset collapses the solid");
}
history.modify(solid, built.shape.clone());
Ok(Built::new(built.shape, history))
}
fn rebuilt_seam_edge(
model: &mut Model,
edge: &Shape,
prep: &Prepared,
new_vertices: &HashMap<TShapeId, (Shape, Point)>,
tol: Tolerances,
) -> OgeomResult<Option<Shape>> {
use ogeom_geom::Curve2d as _;
let old_surface = {
let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
else {
ogeom_bail!(Construction, "face node holds no face data");
};
data.surface
};
let found = {
let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
ogeom_bail!(Construction, "edge node holds no edge data");
};
let mut found = None;
for repr in &data.representations {
if let EdgeRepr::Seam {
forward,
surface,
range,
..
} = repr
&& *surface == old_surface
{
let Some(pcurve) = model.geometry().pcurve(*forward) else {
ogeom_bail!(Dangling, "a seam pcurve is not in this model");
};
found = Some(pcurve.point_at(range.0, tol)?.x);
break;
}
}
found
};
let Some(column) = found else {
return Ok(None);
};
let Some((sv, ev)) = edge_vertices(model, edge)? else {
ogeom_bail!(Construction, "a seam has no vertices");
};
let (Some((v_from, p_from)), Some((v_to, p_to))) = (
new_vertices.get(&sv.node()).cloned(),
new_vertices.get(&ev.node()).cloned(),
) else {
ogeom_bail!(Construction, "a seam end has no re-solved vertex");
};
let Some(curve) = ogeom_algo::surface_iso_u_curve(&prep.surface, column, tol) else {
ogeom_bail!(
Construction,
"the moved surface's iso-curve has no closed form; no seam can \
be rebuilt"
);
};
let along = |p: Point| -> OgeomResult<f64> {
match &curve {
Curve::Line(l) => Ok((p - l.axis().location).dot(l.axis().direction.vector())),
Curve::Circle(c) => {
let local = c.circle().frame().to_local(p);
let mut angle = local.y.atan2(local.x);
if angle < 0.0 {
angle += core::f64::consts::TAU;
}
Ok(angle)
}
_ => Ok(ogeom_algo::project_on_curve(&curve, p, 64, tol)?.parameter),
}
};
let (t_start, t_end) = (along(p_from)?, along(p_to)?);
let slack = if matches!(prep.surface, SurfaceGeometry::BSpline(_)) {
(tol.confusion() * 1e3).max(1e-4) * 4.0
} else {
tol.confusion() * 100.0
};
for (t, p, v) in [(t_start, p_from, &v_from), (t_end, p_to, &v_to)] {
let off = curve.point_at(t, tol)?.distance(p);
if off > slack {
return Ok(None);
}
if off > tol.confusion() {
model.widen(v, ogeom_core::Tolerance::new(off * 2.0)?)?;
}
}
Ok(Some(if t_start <= t_end {
make_edge_between(model, curve, (t_start, t_end), &v_from, &v_to, tol)?.shape
} else {
make_edge_between(model, curve, (t_end, t_start), &v_to, &v_from, tol)?
.shape
.reversed()
}))
}
fn assembled_with_seam(
model: &mut Model,
prep: &Prepared,
wires: &[Vec<Shape>],
new_edges: &HashMap<TShapeId, Shape>,
tol: Tolerances,
) -> OgeomResult<Shape> {
let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
for edges in wires {
rings.push(ogeom_algo::make_wire(model, edges, tol)?.shape);
}
let face = ogeom_algo::make_face(model, prep.surface.clone(), &rings, tol)?.shape;
let new_surface = {
let Some(NodeData::Face(data)) = model.node(&face).map(ogeom_topo::TShape::data) else {
ogeom_bail!(Construction, "the face just built holds no face data");
};
data.surface
};
let old_surface = {
let Some(NodeData::Face(data)) = model.node(&prep.shape).map(ogeom_topo::TShape::data)
else {
ogeom_bail!(Construction, "face node holds no face data");
};
data.surface
};
let mut done: Vec<TShapeId> = Vec::new();
for used in explore(model, &prep.shape, Filter::OfType(ShapeType::Edge))? {
if done.contains(&used.node()) {
continue;
}
done.push(used.node());
let Some(fresh) = new_edges.get(&used.node()).cloned() else {
ogeom_bail!(Construction, "a face edge was not rebuilt");
};
let (fresh_curve, fresh_range) = {
let Some(data) = model.node(&fresh).and_then(|n| n.data().as_edge()) else {
ogeom_bail!(Construction, "a rebuilt edge holds no edge data");
};
let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
ogeom_bail!(Construction, "a rebuilt edge has no curve");
};
let Some(geometry) = model.geometry().curve(*curve) else {
ogeom_bail!(Dangling, "curve is not in this model");
};
(geometry.clone(), *range)
};
let columns = {
let Some(data) = model.node(&used).and_then(|n| n.data().as_edge()) else {
ogeom_bail!(Construction, "edge node holds no edge data");
};
let mut columns = None;
for repr in &data.representations {
if let EdgeRepr::Seam {
forward,
reversed,
surface,
range,
..
} = repr
&& *surface == old_surface
{
use ogeom_geom::Curve2d as _;
let (Some(f), Some(r)) = (
model.geometry().pcurve(*forward),
model.geometry().pcurve(*reversed),
) else {
ogeom_bail!(Dangling, "a seam pcurve is not in this model");
};
columns = Some((f.point_at(range.0, tol)?.x, r.point_at(range.0, tol)?.x));
break;
}
}
columns
};
if let Some((forward_col, reversed_col)) = columns {
let rows = match &prep.surface {
SurfaceGeometry::Cone(c) => {
let cos = c.cone().half_angle().cos();
(fresh_range.0 * cos, fresh_range.1 * cos)
}
_ => fresh_range,
};
let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
Ok(ogeom_geom::Line2d::over(
ogeom_math::Axis2::new(
ogeom_math::Point2::new(u, 0.0),
ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
),
rows.0 - 1.0,
rows.1 + 1.0,
)?
.into())
};
ogeom_algo::attach_seam(
model,
&fresh,
column(forward_col)?,
column(reversed_col)?,
new_surface,
ogeom_topo::Location::identity(),
rows,
)?;
} else {
let pcurve = match ogeom_intersect::exact_pcurve_of(&fresh_curve, &prep.surface, tol) {
Some(exact) => exact,
None => {
let (fitted, _, _, worst_off, _) =
ogeom_algo::pcurve_fit::fit_projected_pcurve(
&fresh_curve,
fresh_range,
&prep.surface,
tol,
)?;
if worst_off > tol.confusion() {
let widened = ogeom_core::Tolerance::new(worst_off + tol.confusion())?;
model.widen(&fresh, widened)?;
if let Some((a, b)) = edge_vertices(model, &fresh)? {
model.widen(&a, widened)?;
model.widen(&b, widened)?;
}
}
fitted
}
};
ogeom_algo::attach_pcurve(
model,
&fresh,
pcurve,
new_surface,
ogeom_topo::Location::identity(),
fresh_range,
)?;
}
}
Ok(face)
}
fn seam_end(
model: &Model,
faces: &[Shape],
prepared: &[Prepared],
vertex: &Shape,
kept: &[usize],
at: Point,
tol: Tolerances,
) -> OgeomResult<Option<Point>> {
use ogeom_geom::Curve2d as _;
for (slot, &fi) in kept.iter().enumerate() {
let other = kept[1 - slot];
let face = &faces[fi];
let Some(NodeData::Face(data)) = model.node(face).map(ogeom_topo::TShape::data) else {
continue;
};
let old_surface = data.surface;
let mut uses: HashMap<TShapeId, usize> = HashMap::new();
for e in explore(model, face, Filter::OfType(ShapeType::Edge))? {
*uses.entry(e.node()).or_insert(0) += 1;
}
for e in explore_unique(model, face, ShapeType::Edge)? {
if uses.get(&e.node()).copied().unwrap_or(0) < 2 {
continue;
}
let Some((a, b)) = edge_vertices(model, &e)? else {
continue;
};
if a.node() != vertex.node() && b.node() != vertex.node() {
continue;
}
let Some(edata) = model.node(&e).and_then(|n| n.data().as_edge()) else {
continue;
};
let mut column = None;
for repr in &edata.representations {
if let EdgeRepr::Seam {
forward,
surface,
range,
..
} = repr
&& *surface == old_surface
&& let Some(pcurve) = model.geometry().pcurve(*forward)
{
column = Some(pcurve.point_at(range.0, tol)?.x);
break;
}
}
let Some(column) = column else {
continue;
};
let Some(iso) = ogeom_algo::surface_iso_u_curve(&prepared[fi].surface, column, tol)
else {
continue;
};
let found = ogeom_intersect::intersect_curve_surface(
&iso,
&prepared[other].surface,
ogeom_intersect::CurveSurfaceOptions::default(),
tol,
)?;
let nearest = found
.crossings
.iter()
.map(|hit| hit.point)
.min_by(|p, q| p.distance(at).total_cmp(&q.distance(at)));
if let Some(p) = nearest {
return Ok(Some(p));
}
}
}
Ok(None)
}
fn rebuilt_lone_edge(
model: &mut Model,
edge: &Shape,
sides: &[usize],
constraint: &Displacement<'_>,
new_vertices: &HashMap<TShapeId, (Shape, Point)>,
tol: Tolerances,
) -> OgeomResult<Option<Shape>> {
use ogeom_geom::Curve3d as _;
let (degenerate, curve) = {
let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
ogeom_bail!(Construction, "edge node holds no edge data");
};
let curve = data.curve3d().and_then(|repr| {
let EdgeRepr::Curve3d { curve, range, .. } = repr else {
return None;
};
model.geometry().curve(*curve).cloned().map(|c| (c, *range))
});
(data.degenerate, curve)
};
let Some((start, end)) = edge_vertices(model, edge)? else {
ogeom_bail!(Construction, "a lone edge has no vertices");
};
if degenerate {
let Some((vertex, _)) = new_vertices.get(&start.node()) else {
ogeom_bail!(Construction, "an apex has no re-solved vertex");
};
let mut data = EdgeData::new();
data.degenerate = true;
return Ok(Some(
model.add_edge(data, &[vertex.clone(), vertex.clone()])?,
));
}
let (Some((Curve::Circle(c), range)), true, &[fi]) = (curve, start.node() == end.node(), sides)
else {
return Ok(None);
};
let circle = c.circle();
let mut moved_points = Vec::with_capacity(3);
for k in 0..3 {
#[allow(clippy::cast_precision_loss, reason = "k is 0..3")]
let t = (range.1 - range.0).mul_add(k as f64 / 3.0, range.0);
let p = Curve::Circle(c).point_at(t, tol)?;
let Some((n, w)) = constraint(model, fi, p)? else {
return Ok(None);
};
moved_points.push(p + n * w);
}
let centre = Point::from_vector(
moved_points
.iter()
.fold(Vector::new(0.0, 0.0, 0.0), |a, p| a + p.to_vector())
/ 3.0,
);
let radius = centre.distance(moved_points[0]);
let reframed = ogeom_math::Circle::new(
Frame::new(centre, circle.frame().z(), circle.frame().x(), tol)?,
radius,
tol,
)?;
let moved: Curve = ogeom_geom::CircleCurve::new(reframed).into();
Ok(Some(make_edge(model, moved, range, tol)?.shape))
}
fn solve_corner(normals: &[Vector], amounts: &[f64], tol: Tolerances) -> OgeomResult<Vector> {
let mut a = [[0.0_f64; 3]; 3];
let mut b = [0.0_f64; 3];
for (n, w) in normals.iter().zip(amounts) {
let row = [n.x, n.y, n.z];
for i in 0..3 {
for j in 0..3 {
a[i][j] += row[i] * row[j];
}
b[i] += row[i] * w;
}
}
if normals.len() == 2 {
let along = normals[0].cross(normals[1]);
let m = along.magnitude();
if m <= tol.angular() {
ogeom_bail!(Construction, "an edge between parallel faces has no corner");
}
let d = along / m;
let row = [d.x, d.y, d.z];
for i in 0..3 {
for j in 0..3 {
a[i][j] += row[i] * row[j];
}
}
}
let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
- a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
if det.abs() <= tol.angular() * tol.angular() {
ogeom_bail!(
Construction,
"a corner's faces are too nearly parallel to re-solve"
);
}
let inv = |r: usize, c: usize| -> f64 {
let (r1, r2) = ((r + 1) % 3, (r + 2) % 3);
let (c1, c2) = ((c + 1) % 3, (c + 2) % 3);
(a[c1][r1] * a[c2][r2] - a[c1][r2] * a[c2][r1]) / det
};
let mut x = [0.0_f64; 3];
for (i, xi) in x.iter_mut().enumerate() {
for (j, bj) in b.iter().enumerate() {
*xi += inv(i, j) * bj;
}
}
Ok(Vector::new(x[0], x[1], x[2]))
}
fn growing_edges(
model: &Model,
body: &Shape,
held: &[TShapeId],
convex: bool,
tol: Tolerances,
) -> OgeomResult<Vec<Shape>> {
use ogeom_geom::{Curve3d as _, Surface as _};
let mut holders: std::collections::HashMap<TShapeId, Vec<(Shape, Shape)>> =
std::collections::HashMap::new();
for face in ogeom_topo::explore(model, body, ogeom_topo::Filter::OfType(ShapeType::Face))? {
for e in ogeom_topo::explore(model, &face, ogeom_topo::Filter::OfType(ShapeType::Edge))? {
holders.entry(e.node()).or_default().push((e, face.clone()));
}
}
let mut out = Vec::new();
for edge in ogeom_topo::explore_unique(model, body, ShapeType::Edge)? {
let mut faces: Vec<Shape> = Vec::new();
for (e, face) in holders.get(&edge.node()).map_or(&[][..], Vec::as_slice) {
if e.is_same(&edge) && !faces.iter().any(|f| f.is_same(face)) {
faces.push(face.clone());
}
}
let mut distinct: Vec<Shape> = Vec::new();
for f in faces {
if !distinct.iter().any(|d| d.node() == f.node()) {
distinct.push(f);
}
}
let [f1, f2] = distinct.as_slice() else {
continue;
};
if held.contains(&f1.node()) || held.contains(&f2.node()) {
continue;
}
let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
continue;
};
let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
continue;
};
let Some(geometry) = model.geometry().curve(*curve) else {
continue;
};
let p = edge
.transform(model.datums())?
.apply(geometry.point_at(f64::midpoint(range.0, range.1), tol)?);
let Some(NodeData::Face(face_data)) = model.node(f1).map(ogeom_topo::TShape::data) else {
continue;
};
let Some(surface) = model.geometry().surface(face_data.surface) else {
continue;
};
use ogeom_geom::Transformable as _;
let placed = surface
.clone()
.transformed(&f1.transform(model.datums())?, tol)?;
let (u, v) = ogeom_algo::project_on_surface(&placed, p, 16, tol)?.parameters;
let mut n1 = placed.normal_at(u, v, tol)?.vector();
if f1.orientation() == Orientation::Reversed {
n1 = -n1;
}
let mesh = ogeom_mesh::triangulate_face(model, f2, ogeom_mesh::Deflection::default(), tol)?;
let below = mesh
.positions
.iter()
.map(|q| (*q - p).dot(n1))
.max_by(|a, b| a.abs().total_cmp(&b.abs()))
.unwrap_or(0.0);
if (convex && below < -tol.confusion()) || (!convex && below > tol.confusion()) {
out.push(edge);
}
}
Ok(out)
}