Extend works on any surface: natural (continuing the face's own surface), tangent (G1 run-off) or curvature (G2 run-off), by a distance, up to a plane or face, or back to the surface's bounds, with neighbouring edges lengthened to meet. Untrim and Extend share face parameter-loop helpers. The new SurfaceExtend fields are noted under format 21. Closes #760.
This commit is contained in:
1 parent
41f0c3d0c6
commit
b4edf05373
18 files changed
+2271
-263
No files matched your search
@@ -194,6 +194,7 @@ impl crate::Feature {
|
||||
| K::SurfaceTrim { plane, .. } => checked_plane(plane, 0)?,
|
||||
K::Draft { neutral, .. } => checked_plane(neutral, 0)?,
|
||||
K::ReplaceFace { target, .. } => checked_plane(target, 0)?,
|
||||
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => checked_plane(plane, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
@@ -248,6 +249,7 @@ impl crate::Feature {
|
||||
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, names, bodies, 0)?,
|
||||
K::Draft { neutral, .. } => remap_plane(neutral, ids, names, bodies, 0)?,
|
||||
K::ReplaceFace { target, .. } => remap_plane(target, ids, names, bodies, 0)?,
|
||||
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => remap_plane(plane, ids, names, bodies, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs_mut() {
|
||||
remap_geo(r, ids, names, bodies, 0)?;
|
||||
|
||||
@@ -1137,11 +1137,20 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
face: Option<Vec3>,
|
||||
},
|
||||
/// A planar surface body extended outward by `distance` past the edges through `edges`.
|
||||
/// A surface body's boundary edges through `edges` extended outward: by `distance`, or up
|
||||
/// to `up_to`; the surface continued (`mode` natural) or a run-off face along each edge
|
||||
/// (tangent, curvature). `neighbours`: the edges next to an extended edge are lengthened to
|
||||
/// meet it (else the extension's sides run square to the edge).
|
||||
SurfaceExtend {
|
||||
body: String,
|
||||
edges: Vec<Vec3>,
|
||||
distance: String,
|
||||
#[serde(default, skip_serializing_if = "ExtendMode::is_natural")]
|
||||
mode: ExtendMode,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
up_to: Option<ExtendTo>,
|
||||
#[serde(default = "yes", skip_serializing_if = "is_yes")]
|
||||
neighbours: bool,
|
||||
},
|
||||
/// A surface body trimmed by a plane, or by a face of a body (`tool`), keeping the side of
|
||||
/// `keep`.
|
||||
@@ -1189,6 +1198,37 @@ pub enum FeatureKind {
|
||||
},
|
||||
}
|
||||
|
||||
/// How Extend continues a surface past its edges.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExtendMode {
|
||||
/// The surface itself, continued.
|
||||
#[default]
|
||||
Natural,
|
||||
/// A ruled run-off in the surface's tangent plane (G1).
|
||||
Tangent,
|
||||
/// A run-off that keeps the surface's curvature across the edge (G2).
|
||||
Curvature,
|
||||
}
|
||||
|
||||
impl ExtendMode {
|
||||
pub fn is_natural(&self) -> bool {
|
||||
*self == ExtendMode::Natural
|
||||
}
|
||||
}
|
||||
|
||||
/// What Extend extends up to instead of a distance.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExtendTo {
|
||||
/// A plane.
|
||||
Plane(PlaneRef),
|
||||
/// The surface of a body's face (extended).
|
||||
Face(FaceAt),
|
||||
/// The surface's own bounds (natural only): a trim undone.
|
||||
Bounds,
|
||||
}
|
||||
|
||||
/// How a fillet's size is given.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
|
||||
@@ -78,11 +78,30 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceExtend { body, edges, distance } => {
|
||||
let d = val(vals, distance, Kind::Length)?;
|
||||
FeatureKind::SurfaceExtend { body, edges, distance, mode, up_to, neighbours } => {
|
||||
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let out = kernel::extend(&mb.body, edges, d)?;
|
||||
let mode = match mode {
|
||||
crate::ExtendMode::Natural => kernel::ExtendMode::Natural,
|
||||
crate::ExtendMode::Tangent => kernel::ExtendMode::Tangent,
|
||||
crate::ExtendMode::Curvature => kernel::ExtendMode::Curvature,
|
||||
};
|
||||
let at = |limit| kernel::ExtendOptions { mode, limit, neighbours: *neighbours };
|
||||
let out = match up_to {
|
||||
None => kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::Distance(val(vals, distance, Kind::Length)?)))?,
|
||||
Some(crate::ExtendTo::Bounds) => kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::Bounds))?,
|
||||
Some(crate::ExtendTo::Plane(plane)) => {
|
||||
let tool = kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?);
|
||||
kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::UpTo(&tool)))?
|
||||
}
|
||||
Some(crate::ExtendTo::Face(t)) => {
|
||||
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
|
||||
let face = crate::appearance::face_index_at(tb, t.point)
|
||||
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the target point", t.body)))?;
|
||||
let tool = kernel::SplitTool::Face { body: &tb.body, face };
|
||||
kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::UpTo(&tool)))?
|
||||
}
|
||||
};
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, out, mb.feature);
|
||||
}
|
||||
|
||||
@@ -139,11 +139,21 @@ macro_rules! geometry_walk {
|
||||
f(Pos(start));
|
||||
}
|
||||
}
|
||||
FeatureKind::Chamfer { edges, .. } | FeatureKind::SheetHem { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => {
|
||||
FeatureKind::Chamfer { edges, .. } | FeatureKind::SheetHem { edges, .. } => {
|
||||
for e in edges {
|
||||
f(Pos(e));
|
||||
}
|
||||
}
|
||||
FeatureKind::SurfaceExtend { edges, up_to, .. } => {
|
||||
for e in edges {
|
||||
f(Pos(e));
|
||||
}
|
||||
match up_to {
|
||||
Some(crate::ExtendTo::Plane(plane)) => f(Plane(plane)),
|
||||
Some(crate::ExtendTo::Face(t)) => f(Pos(& $($m)? t.point)),
|
||||
Some(crate::ExtendTo::Bounds) | None => {}
|
||||
}
|
||||
}
|
||||
FeatureKind::Shell { faces, .. }
|
||||
| FeatureKind::OffsetFace { faces, .. }
|
||||
| FeatureKind::DeleteFace { faces, .. }
|
||||
|
||||
@@ -175,8 +175,15 @@ macro_rules! feature_walk {
|
||||
}
|
||||
}
|
||||
FeatureKind::SurfaceUntrim { face, .. } => f(B(& $($m)? face.body)),
|
||||
FeatureKind::SurfaceExtend { body, up_to, .. } => {
|
||||
f(B(body));
|
||||
match up_to {
|
||||
Some(crate::ExtendTo::Plane(plane)) => $plane(plane, f),
|
||||
Some(crate::ExtendTo::Face(t)) => f(B(& $($m)? t.body)),
|
||||
Some(crate::ExtendTo::Bounds) | None => {}
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetConvert { body, .. }
|
||||
| FeatureKind::SurfaceExtend { body, .. }
|
||||
| FeatureKind::SurfaceRuled { body, .. }
|
||||
| FeatureKind::Unstitch { body, .. }
|
||||
| FeatureKind::ReverseNormal { body, .. }
|
||||
|
||||
@@ -153,6 +153,14 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
("reverse_normal", json!({"type": "reverse_normal", "body": "B"})),
|
||||
("surface_untrim", json!({"type": "surface_untrim", "face": {"body": "B", "point": o}, "holes": [o], "outer": true})),
|
||||
("surface_extend", json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance"})),
|
||||
(
|
||||
"surface_extend_to_plane",
|
||||
json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance", "mode": "curvature", "up_to": {"plane": plane("extend")}}),
|
||||
),
|
||||
(
|
||||
"surface_extend_to_face",
|
||||
json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance", "up_to": {"face": {"body": "B", "point": o}}, "neighbours": false}),
|
||||
),
|
||||
(
|
||||
"surface_ruled",
|
||||
json!({"type": "surface_ruled", "body": "B", "edges": [o], "distance": "ruled.distance", "mode": "angle", "angle": "ruled.angle",
|
||||
|
||||
@@ -330,6 +330,16 @@ surface_extend
|
||||
inputs:
|
||||
Distance = extend.distance (Length)
|
||||
expressions: extend.distance
|
||||
surface_extend_to_plane
|
||||
inputs:
|
||||
Distance = extend.distance (Length)
|
||||
Angle = extend.angle (Angle)
|
||||
Offset = extend.offset (Length)
|
||||
expressions: extend.distance, extend.angle, extend.offset
|
||||
surface_extend_to_face
|
||||
inputs:
|
||||
Distance = extend.distance (Length)
|
||||
expressions: extend.distance
|
||||
surface_ruled
|
||||
inputs:
|
||||
Distance = ruled.distance (Length)
|
||||
|
||||
@@ -155,6 +155,10 @@ surface_untrim
|
||||
face points: [0, 0, 0]
|
||||
surface_extend
|
||||
moves: edges[0][0], edges[0][1], edges[0][2]
|
||||
surface_extend_to_plane
|
||||
moves: edges[0][0], edges[0][1], edges[0][2], up_to.plane.axis_dir[0], up_to.plane.axis_dir[1], up_to.plane.axis_origin[0], up_to.plane.axis_origin[1], up_to.plane.axis_origin[2]
|
||||
surface_extend_to_face
|
||||
moves: edges[0][0], edges[0][1], edges[0][2], up_to.face.point[0], up_to.face.point[1], up_to.face.point[2]
|
||||
surface_ruled
|
||||
moves: direction[0], direction[2], edges[0][0], edges[0][1], edges[0][2], face[1], face[2]
|
||||
face points: [1, 0, 0]
|
||||
|
||||
@@ -172,6 +172,11 @@ surface_untrim
|
||||
remap_bodies: face.body
|
||||
surface_extend
|
||||
remap_bodies: body
|
||||
surface_extend_to_plane
|
||||
remap_refs: up_to.plane.base.base.name
|
||||
remap_bodies: body
|
||||
surface_extend_to_face
|
||||
remap_bodies: body, up_to.face.body
|
||||
surface_ruled
|
||||
remap_bodies: body
|
||||
surface_trim
|
||||
|
||||
@@ -173,6 +173,12 @@
|
||||
//! A `SurfaceRuled` feature (Ruled Surface: `body`, `edges` points, `distance`, optional
|
||||
//! `mode`, `angle`, `direction`, `flip`, `corners` and `face`) is also first saved in 21;
|
||||
//! 0.11.0 asks for an update instead of meeting a feature it can't read.
|
||||
//! Under the same version, a `SurfaceExtend` can say how it continues the surface (`mode`:
|
||||
//! `tangent` or `curvature`; `natural` when absent), extend up to something instead of its
|
||||
//! `distance` (`up_to`: a `plane`, a `face` as `body` and `point`, or `bounds`) and keep the
|
||||
//! neighbouring edges as they are (`neighbours: false`). An Extend without them builds the
|
||||
//! planar extension it always did and saves as before; 0.11.0 asks for an update instead of
|
||||
//! dropping them and extending a plane by the distance.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -353,7 +353,12 @@ macro_rules! feature_walk {
|
||||
FeatureKind::MeshConvert { tolerance, .. } => f("Tolerance", tolerance, L, I),
|
||||
FeatureKind::Thicken { thickness, .. } => f("Thickness", thickness, L, I),
|
||||
FeatureKind::SurfaceTrim { plane, .. } => $plane(plane, f),
|
||||
FeatureKind::SurfaceExtend { distance, .. } => f("Distance", distance, L, I),
|
||||
FeatureKind::SurfaceExtend { distance, up_to, .. } => {
|
||||
f("Distance", distance, L, I);
|
||||
if let Some(crate::ExtendTo::Plane(plane)) = up_to {
|
||||
$plane(plane, f);
|
||||
}
|
||||
}
|
||||
FeatureKind::SurfaceRuled { distance, angle, .. } => {
|
||||
f("Distance", distance, L, I);
|
||||
if let Some(a) = angle {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! solids), and the planned surface toolset (`planned!`: registered, not built yet).
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{FaceAt, FeatureKind, PlaneRef, ProfileSel, RuledCorners, RuledMode, expr::Kind};
|
||||
use solidcraft_doc::{ExtendMode, ExtendTo, FaceAt, FeatureKind, PlaneRef, ProfileSel, RuledCorners, RuledMode, expr::Kind};
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, axis_param, check_expr, extent_param, feature_sketch, operation, plane_param, profiles, sketch_id};
|
||||
@@ -25,7 +25,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("surface.extend", "Extend", extend)
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("extend")
|
||||
.params("body: planar surface name; edges: [[x,y,z]] (points on straight edges to push out); distance: expr"),
|
||||
.params("body: surface name; edges: [[x,y,z]] (points on boundary edges to push out); distance: expr (unless up_to); mode?: natural (the surface itself continued: exact for planes and surfaces of revolution, a B-spline's end spans continued; the default) | tangent (a ruled run-off face along each edge, G1) | curvature (a run-off face keeping the surface's curvature across the edge, G2); up_to?: plane (XY|XZ|YZ|plane name|{origin, normal}) | {body, point} (a face, its surface extended) | \"bounds\" (natural: back to the surface's own bounds, undoing a trim); neighbours?: bool (default true: the edges next to an extended edge are lengthened to meet it; false: the extension's sides run square to the edge)"),
|
||||
CommandSpec::new("surface.trim", "Trim", trim)
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("trim")
|
||||
@@ -329,9 +329,36 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if edges.is_empty() || edges.len() > 10_000 {
|
||||
return Err(bad(cmd, "`edges` must list points on the edges to extend"));
|
||||
}
|
||||
let distance = expr(p, "distance").ok_or_else(|| bad(cmd, "`distance` is required"))?;
|
||||
let mode = match str_(p, "mode").map(str::trim) {
|
||||
None | Some("natural") => ExtendMode::Natural,
|
||||
Some("tangent") => ExtendMode::Tangent,
|
||||
Some("curvature") => ExtendMode::Curvature,
|
||||
Some(m) => return Err(bad(cmd, format!("`mode` must be natural, tangent or curvature, not `{m}`"))),
|
||||
};
|
||||
let up_to = match p.get("up_to").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
Some(Value::String(b)) if b.trim() == "bounds" => Some(ExtendTo::Bounds),
|
||||
Some(t @ Value::Object(o)) if o.contains_key("body") => {
|
||||
let tb = t.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`up_to.body` must name a body"))?.to_string();
|
||||
if s.model.state().body(&tb).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{tb}`")));
|
||||
}
|
||||
let point = t.get("point").and_then(vec3).ok_or_else(|| bad(cmd, "`up_to.point` must be [x, y, z] on the target face"))?;
|
||||
Some(ExtendTo::Face(FaceAt { body: tb, point }))
|
||||
}
|
||||
Some(v) => Some(ExtendTo::Plane(plane_param(s, Some(v), cmd)?)),
|
||||
};
|
||||
if up_to == Some(ExtendTo::Bounds) && mode != ExtendMode::Natural {
|
||||
return Err(bad(cmd, "only a natural extension goes back to the surface's bounds"));
|
||||
}
|
||||
let distance = match (expr(p, "distance"), &up_to) {
|
||||
(Some(d), _) => d,
|
||||
(None, Some(_)) => "0 mm".into(),
|
||||
(None, None) => return Err(bad(cmd, "`distance` is required (or `up_to`)")),
|
||||
};
|
||||
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
|
||||
add_feature(s, p, FeatureKind::SurfaceExtend { body, edges, distance })
|
||||
let neighbours = bool_(p, "neighbours").unwrap_or(true);
|
||||
add_feature(s, p, FeatureKind::SurfaceExtend { body, edges, distance, mode, up_to, neighbours })
|
||||
}
|
||||
|
||||
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -871,6 +898,102 @@ mod tests {
|
||||
assert!((area(&s, "Top") - 800.0).abs() < 1e-9, "{}", area(&s, "Top"));
|
||||
}
|
||||
|
||||
/// A quarter cylinder sheet (radius 10, heights 0…20) swept from a sketch arc.
|
||||
fn quarter_cylinder(s: &mut Session, name: &str) {
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Arc"}));
|
||||
let r = 10.0 / 2f64.sqrt();
|
||||
let arc = run(s, "sketch.arc.three_point", json!({"start": [10, 0], "end": [0, 10], "through": [r, r]}))["created"]["curves"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "surface.extrude", json!({"sketch": "Arc", "curves": [arc], "distance": 20, "body_name": name}));
|
||||
}
|
||||
|
||||
/// Extend a cylinder strip: naturally 5 along its axis (10 · π/2 · 25, one face, still a
|
||||
/// cylinder), up to the plane z = 30 (10 · π/2 · 30), and by tangent and curvature run-offs
|
||||
/// (a second face along a straight edge, 20 long: the tangent one a flat 5 × 20, the
|
||||
/// curvature one the parabola s ↦ s·R + ½κs²·n with the cylinder's κ = 1/10, its section
|
||||
/// ∫₀⁵ √(1 + κ²s²) ds long). Undo, redo and reopening; the default saves nothing new.
|
||||
#[test]
|
||||
fn extend_a_cylinder_strip_every_way() {
|
||||
let pi = std::f64::consts::PI;
|
||||
let mut s = Session::default();
|
||||
quarter_cylinder(&mut s, "Strip");
|
||||
let top = [10.0 * (pi / 4.0).cos(), 10.0 * (pi / 4.0).sin(), 20.0];
|
||||
run(&mut s, "surface.extend", json!({"body": "Strip", "edges": [top], "distance": "5 mm", "name": "Up"}));
|
||||
let want = 10.0 * pi / 2.0 * 25.0;
|
||||
assert!((area(&s, "Strip") - want).abs() < want * 1e-3, "{} vs {want}", area(&s, "Strip")); // (measured on meshes)
|
||||
assert_eq!(s.world_state().body("Strip").unwrap().body.face_count(), 1);
|
||||
let saved = s.doc.to_json();
|
||||
assert!(!saved.contains("\"mode\"") && !saved.contains("\"up_to\"") && !saved.contains("\"neighbours\""), "the default saves as before");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!((area(&s, "Strip") - 200.0 * pi / 4.0 * 2.0).abs() < 1.0);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((area(&cold, "Strip") - area(&s, "Strip")).abs() < 1e-9);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
|
||||
run(&mut s, "surface.extend", json!({"body": "Strip", "edges": [top], "up_to": {"origin": [0, 0, 30], "normal": [0, 0, 1]}}));
|
||||
let want = 10.0 * pi / 2.0 * 30.0;
|
||||
assert!((area(&s, "Strip") - want).abs() < want * 1e-3, "{} vs {want}", area(&s, "Strip")); // (measured on meshes)
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
|
||||
let side = [0.0, 10.0, 10.0];
|
||||
let k: f64 = 0.1;
|
||||
let parabola = 2.5 * (1.0 + 25.0 * k * k).sqrt() + (5.0 * k).asinh() / (2.0 * k);
|
||||
for (mode, run_off) in [("tangent", 5.0), ("curvature", parabola)] {
|
||||
run(&mut s, "surface.extend", json!({"body": "Strip", "edges": [side], "distance": 5, "mode": mode}));
|
||||
let st = s.world_state();
|
||||
assert_eq!(st.body("Strip").unwrap().body.face_count(), 2, "{mode}");
|
||||
let want = 10.0 * pi / 2.0 * 20.0 + run_off * 20.0;
|
||||
assert!((area(&s, "Strip") - want).abs() < want * 1e-3, "{mode}: {} vs {want}", area(&s, "Strip")); // (measured on meshes)
|
||||
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((area(&cold, "Strip") - area(&s, "Strip")).abs() < 1e-9, "{mode}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The half cylinder trimmed by a slanted plane goes back to its span (150π) when its top
|
||||
/// edge is extended to the surface's bounds.
|
||||
#[test]
|
||||
fn extend_back_to_the_surfaces_bounds() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
let arc = run(&mut s, "sketch.arc.three_point", json!({"start": [10, 0], "end": [-10, 0], "through": [0, 10]}))["created"]["curves"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "surface.extrude", json!({"sketch": "S", "curves": [arc], "distance": 20, "body_name": "Half"}));
|
||||
run(&mut s, "surface.trim", json!({"body": "Half", "plane": {"origin": [0, 0, 15], "normal": [0, 0.5, 1]}, "keep": [0, 10, 1]}));
|
||||
run(&mut s, "surface.extend", json!({"body": "Half", "edges": [[0, 10, 10]], "up_to": "bounds"}));
|
||||
let want = 150.0 * std::f64::consts::PI;
|
||||
assert!((area(&s, "Half") - want).abs() < want * 1e-3, "{} vs {want}", area(&s, "Half")); // (measured on meshes)
|
||||
}
|
||||
|
||||
/// Requests Extend can't take are refused and leave the design as it was.
|
||||
#[test]
|
||||
fn extend_refuses_bad_requests() {
|
||||
let mut s = Session::default();
|
||||
quarter_cylinder(&mut s, "Strip");
|
||||
let before = s.doc.features.len();
|
||||
let top = [10.0 * (std::f64::consts::PI / 4.0).cos(), 10.0 * (std::f64::consts::PI / 4.0).sin(), 20.0];
|
||||
for p in [
|
||||
json!({"body": "Strip", "edges": [top]}),
|
||||
json!({"body": "Strip", "edges": [top], "distance": 5, "mode": "sideways"}),
|
||||
json!({"body": "Strip", "edges": [top], "up_to": "bounds", "mode": "tangent"}),
|
||||
json!({"body": "Strip", "edges": [top], "up_to": {"body": "Nope", "point": [0, 0, 0]}}),
|
||||
json!({"body": "Strip", "edges": [top], "up_to": {"body": "Strip"}}),
|
||||
json!({"body": "Strip", "edges": [top], "up_to": "No plane"}),
|
||||
json!({"body": "Strip", "edges": [], "distance": 5}),
|
||||
json!({"body": "Nope", "edges": [top], "distance": 5}),
|
||||
] {
|
||||
assert!(s.execute("surface.extend", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.doc.features.len(), before);
|
||||
}
|
||||
|
||||
/// The planned surface commands are registered but refuse to run: disabled with the
|
||||
/// issue that tracks them, an error (never a panic) from every caller, and the design left
|
||||
/// as it was.
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! A face's boundary in its surface's parameters, and exact parameter lines on a surface:
|
||||
//! shared by the operations that rework a face on the surface it lies on (Untrim, Extend).
|
||||
//!
|
||||
//! A face's loops are sampled into parameters with periodic directions unwrapped along each
|
||||
//! loop; which loop is the outer one is worked out from those parameters (the loop enclosing
|
||||
//! the most area), never from the order the loops are stored in: an imported face can list
|
||||
//! its holes first.
|
||||
|
||||
use mt::{BoundedCurve, Cut, InnerSpace, Invertible, MetricSpace, ParametricCurve, ParametricSurface, SearchNearestParameter, Transformed};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::tess::EvalSurface;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::from_p3;
|
||||
use crate::tess::uv::{self, Domain};
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
/// Samples along each boundary edge when working out the face's parameter loops.
|
||||
pub(crate) const EDGE_SAMPLES: usize = 32;
|
||||
/// Golden-section steps that place an extreme of the face's span on an edge.
|
||||
const EXTREME_STEPS: usize = 60;
|
||||
|
||||
/// A boundary loop of a face sampled in 3D and in the surface's parameters: for each of its
|
||||
/// edges (as the loop runs) the curve parameters, points and parameters of its samples, ends
|
||||
/// included. Periodic parameters are unwrapped along the loop.
|
||||
pub(crate) struct LoopSamples {
|
||||
pub(crate) edges: Vec<(mt::Curve, Vec<(f64, Vec3, [f64; 2])>)>,
|
||||
}
|
||||
|
||||
impl LoopSamples {
|
||||
/// The loop as a polygon in parameters (each edge's samples but its last, which the next
|
||||
/// edge starts on).
|
||||
pub(crate) fn polygon(&self) -> Vec<[f64; 2]> {
|
||||
self.edges.iter().flat_map(|(_, s)| s.iter().take(s.len().saturating_sub(1)).map(|x| x.2)).collect()
|
||||
}
|
||||
|
||||
/// Signed area of the loop in parameters (positive: counter-clockwise).
|
||||
pub(crate) fn area(&self) -> f64 {
|
||||
let poly = self.polygon();
|
||||
let n = poly.len();
|
||||
(0..n)
|
||||
.filter_map(|i| {
|
||||
let (a, b) = (poly.get(i)?, poly.get((i + 1) % n)?);
|
||||
Some(0.5 * (a[0] * b[1] - b[0] * a[1]))
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// How far `p` is from the loop (from its samples).
|
||||
pub(crate) fn dist(&self, p: Vec3) -> f64 {
|
||||
self.edges.iter().flat_map(|(_, s)| s.iter().map(|x| x.1.dist(p))).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
}
|
||||
|
||||
/// A face's boundary loops (absolute: as they run on its surface) in the surface's parameters,
|
||||
/// and which of them is the outer one.
|
||||
pub(crate) struct FaceLoops {
|
||||
pub(crate) eval: EvalSurface,
|
||||
pub(crate) dom: Domain,
|
||||
pub(crate) wires: Vec<mt::Wire>,
|
||||
pub(crate) loops: Vec<LoopSamples>,
|
||||
pub(crate) outer: usize,
|
||||
}
|
||||
|
||||
impl FaceLoops {
|
||||
/// The loops of `face`, its boundary found on its surface within `accept`; `what` names the
|
||||
/// operation for a surface no evaluator takes.
|
||||
pub(crate) fn of(face: &mt::Face, accept: f64, what: &str) -> Result<FaceLoops> {
|
||||
let eval = crate::tess::surface::eval_surface(&face.surface())
|
||||
.ok_or_else(|| KernelError::TooLarge(format!("{what} a face on this kind of surface")))?;
|
||||
let dom = uv::domain(&eval);
|
||||
let wires: Vec<mt::Wire> = face.absolute_boundaries().clone();
|
||||
let loops: Vec<LoopSamples> = wires.iter().map(|w| sample_loop(&eval, &dom, w, accept)).collect::<Result<_>>()?;
|
||||
// The outer loop encloses the others: the most area in parameters.
|
||||
let outer = loops
|
||||
.iter()
|
||||
.map(LoopSamples::area)
|
||||
.enumerate()
|
||||
.max_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
|
||||
.map(|(i, _)| i)
|
||||
.ok_or_else(|| KernelError::Invalid("the face has no boundary".into()))?;
|
||||
Ok(FaceLoops { eval, dom, wires, loops, outer })
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample a boundary loop (absolute: as it runs on the surface) in the surface's parameters.
|
||||
pub(crate) fn sample_loop(s: &EvalSurface, dom: &Domain, wire: &mt::Wire, accept: f64) -> Result<LoopSamples> {
|
||||
let mut edges = Vec::new();
|
||||
let mut prev: Option<[f64; 2]> = None;
|
||||
for e in wire.edge_iter() {
|
||||
let c = e.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let mut samples = Vec::with_capacity(EDGE_SAMPLES + 1);
|
||||
for k in 0..=EDGE_SAMPLES {
|
||||
let t = t0 + (t1 - t0) * k as f64 / EDGE_SAMPLES as f64;
|
||||
let p = from_p3(c.subs(t));
|
||||
let q = parameters(s, dom, p, prev, accept)?;
|
||||
samples.push((t, p, q));
|
||||
prev = Some(q);
|
||||
}
|
||||
edges.push((c, samples));
|
||||
}
|
||||
Ok(LoopSamples { edges })
|
||||
}
|
||||
|
||||
/// The parameters of `p` on the surface, unwrapped to lie near `near` along periodic directions.
|
||||
pub(crate) fn parameters(s: &EvalSurface, dom: &Domain, p: Vec3, near: Option<[f64; 2]>, accept: f64) -> Result<[f64; 2]> {
|
||||
let mut q = uv::project(s, dom, p, near, accept).ok_or_else(|| KernelError::Failed("the face's boundary does not lie on its surface".into()))?;
|
||||
if let Some(n) = near {
|
||||
for d in 0..2 {
|
||||
q[d] = uv::unwrap(q[d], n[d], dom.period[d]);
|
||||
}
|
||||
}
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
/// The parameter box the outer boundary is restored to: along each direction the surface's own
|
||||
/// range where it has one of its own (a free-form surface's; not a periodic or unbounded
|
||||
/// direction, and not an analytic surface's), else the face's own span.
|
||||
pub(crate) fn natural_span(s: &EvalSurface, dom: &Domain, outer: &LoopSamples, analytic: bool, accept: f64) -> Result<[(f64, f64); 2]> {
|
||||
let mut span = [(0.0, 0.0); 2];
|
||||
for (d, out) in span.iter_mut().enumerate() {
|
||||
*out = match (dom.range.get(d).copied().flatten(), dom.period.get(d).copied().flatten()) {
|
||||
(Some(range), None) if !analytic => range,
|
||||
(_, period) => {
|
||||
let lo = extreme(s, dom, outer, d, false, accept);
|
||||
let hi = extreme(s, dom, outer, d, true, accept);
|
||||
if let Some(p) = period
|
||||
&& hi - lo >= p * (1.0 - 1e-9)
|
||||
{
|
||||
return Err(KernelError::TooLarge("a face that goes all the way round its surface".into()));
|
||||
}
|
||||
(lo, hi)
|
||||
}
|
||||
};
|
||||
if !(out.1 - out.0 > 1e-12 * (1.0 + out.0.abs() + out.1.abs())) || !out.0.is_finite() || !out.1.is_finite() {
|
||||
return Err(KernelError::Invalid("the face has no extent along its surface".into()));
|
||||
}
|
||||
}
|
||||
Ok(span)
|
||||
}
|
||||
|
||||
/// The least (`max == false`) or greatest parameter along direction `d` the loop reaches: the
|
||||
/// extreme sample, then placed on its edge between the neighbouring samples. Samples where the
|
||||
/// surface does not move along `d` (a pole) say nothing about `d` and are passed over.
|
||||
pub(crate) fn extreme(s: &EvalSurface, dom: &Domain, lp: &LoopSamples, d: usize, max: bool, accept: f64) -> f64 {
|
||||
let sign = if max { 1.0 } else { -1.0 };
|
||||
let mut best: Option<(f64, usize, usize)> = None;
|
||||
for (ei, (_, samples)) in lp.edges.iter().enumerate() {
|
||||
for (k, (_, _, q)) in samples.iter().enumerate() {
|
||||
if uv::degenerate(s, *q, d) {
|
||||
continue;
|
||||
}
|
||||
let x = sign * q[d];
|
||||
if best.is_none_or(|(b, _, _)| x > b) {
|
||||
best = Some((x, ei, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((x, ei, k)) = best else { return f64::NAN };
|
||||
let Some((curve, samples)) = lp.edges.get(ei) else { return sign * x };
|
||||
let (Some(at), Some(lo), Some(hi)) = (samples.get(k), samples.get(k.saturating_sub(1)), samples.get(k + 1).or(samples.get(k))) else {
|
||||
return sign * x;
|
||||
};
|
||||
// Golden-section search for the extreme between the neighbouring samples.
|
||||
let value = |t: f64| -> Option<f64> {
|
||||
let p = from_p3(curve.subs(t));
|
||||
let q = uv::project(s, dom, p, Some(at.2), accept)?;
|
||||
Some(sign * uv::unwrap(q[d], at.2[d], dom.period[d]))
|
||||
};
|
||||
let g = (5f64.sqrt() - 1.0) / 2.0;
|
||||
let (mut a, mut b) = (lo.0, hi.0);
|
||||
let mut best = x;
|
||||
for _ in 0..EXTREME_STEPS {
|
||||
let (c, e) = (b - g * (b - a), a + g * (b - a));
|
||||
let (Some(fc), Some(fe)) = (value(c), value(e)) else { break };
|
||||
best = best.max(fc).max(fe);
|
||||
if fc > fe {
|
||||
b = e;
|
||||
} else {
|
||||
a = c;
|
||||
}
|
||||
}
|
||||
sign * best
|
||||
}
|
||||
|
||||
/// Whether the curve passes within `tol` of `p`.
|
||||
pub(crate) fn passes(curve: &mt::Curve, p: mt::Point3, tol: f64) -> bool {
|
||||
let (t0, t1) = curve.range_tuple();
|
||||
let start = (0..=64)
|
||||
.map(|k| t0 + (t1 - t0) * k as f64 / 64.0)
|
||||
.min_by(|a, b| curve.subs(*a).distance(p).total_cmp(&curve.subs(*b).distance(p)))
|
||||
.unwrap_or(t0);
|
||||
let t = curve.search_nearest_parameter(p, Some(start), 100).unwrap_or(start).clamp(t0.min(t1), t0.max(t1));
|
||||
curve.subs(t).distance(p) <= tol
|
||||
}
|
||||
|
||||
/// The parameter line of `surface` at `w` (v = w when `along_u`, else u = w) from parameter
|
||||
/// `from` to `to` along it, exact: a line on a plane, a B-spline surface's iso-curve, and on a
|
||||
/// revolved surface the profile turned to its angle or an arc about the axis.
|
||||
pub(crate) fn iso_curve(surface: &mt::Surface, along_u: bool, w: f64, from: f64, to: f64) -> Result<mt::Curve> {
|
||||
let at = |t: f64| if along_u { surface.subs(t, w) } else { surface.subs(w, t) };
|
||||
match surface {
|
||||
mt::Surface::Plane(_) => Ok(mt::Curve::Line(mt::Line(at(from), at(to)))),
|
||||
mt::Surface::BSplineSurface(_) | mt::Surface::NurbsSurface(_) => {
|
||||
let hom: mt::BSplineSurface<mt::Vector4> = match surface {
|
||||
mt::Surface::BSplineSurface(b) => mt::BSplineSurface::new(
|
||||
b.knot_vecs().clone(),
|
||||
b.control_points().iter().map(|row| row.iter().map(|p| p.to_homogeneous()).collect()).collect(),
|
||||
),
|
||||
mt::Surface::NurbsSurface(n) => n.non_rationalized().clone(),
|
||||
_ => return Err(KernelError::Failed("not a B-spline surface".into())),
|
||||
};
|
||||
let iso = crate::step_in::split::iso_bsp(&hom, w, along_u).map_err(KernelError::Failed)?;
|
||||
Ok(piece(mt::Curve::NurbsCurve(iso), from, to))
|
||||
}
|
||||
mt::Surface::RevolutedCurve(proc) => {
|
||||
let rev = proc.entity();
|
||||
// Truck's revolved surface is (profile parameter, angle), swapped when the processor
|
||||
// is turned over; the processor's matrix places it.
|
||||
let along_profile = along_u == proc.orientation();
|
||||
let transform = *proc.transform();
|
||||
let (origin, axis) = (rev.origin(), rev.axis().normalize());
|
||||
if along_profile {
|
||||
let profile = rev.entity_curve();
|
||||
if let mt::Curve::Line(_) = profile {
|
||||
return Ok(mt::Curve::Line(mt::Line(at(from), at(to))));
|
||||
}
|
||||
if let mt::Curve::IntersectionCurve(_) = profile {
|
||||
return Err(KernelError::TooLarge("untrimming a surface revolved from an intersection curve".into()));
|
||||
}
|
||||
let turn = mt::Matrix4::from_translation(origin - mt::Point3::new(0.0, 0.0, 0.0))
|
||||
* mt::Matrix4::from_axis_angle(axis, mt::Rad(w))
|
||||
* mt::Matrix4::from_translation(mt::Point3::new(0.0, 0.0, 0.0) - origin);
|
||||
Ok(piece(profile.transformed(transform * turn), from, to))
|
||||
} else {
|
||||
// The profile point at `w` turns about the axis: an arc from angle `from` to `to`.
|
||||
let p = rev.entity_curve().subs(w);
|
||||
let centre = origin + axis * (p - origin).dot(axis);
|
||||
let r = (p - centre).magnitude();
|
||||
if !(r > 1e-12) {
|
||||
return Err(KernelError::Failed("an arc on the axis of a revolved surface".into()));
|
||||
}
|
||||
let x = (p - centre) / r;
|
||||
let frame = crate::step_in::geom::Frame { o: centre, x, y: axis.cross(x), z: axis };
|
||||
let arc = if to > from {
|
||||
crate::step_in::geom::conic_arc(&frame, r, r, from, to - from)
|
||||
} else {
|
||||
crate::step_in::geom::conic_arc(&frame, r, r, to, from - to).inverse()
|
||||
};
|
||||
Ok(mt::Curve::NurbsCurve(arc).transformed(transform))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The piece of `curve` between its parameters `from` and `to`, running from `from`.
|
||||
pub(crate) fn piece(mut curve: mt::Curve, from: f64, to: f64) -> mt::Curve {
|
||||
let (lo, hi) = if from < to { (from, to) } else { (to, from) };
|
||||
let (t0, t1) = curve.range_tuple();
|
||||
let eps = (t1 - t0).abs() * 1e-12;
|
||||
if lo > t0 + eps {
|
||||
curve = curve.cut(lo);
|
||||
}
|
||||
if hi < t1 - eps {
|
||||
let _ = curve.cut(hi);
|
||||
}
|
||||
if from > to {
|
||||
curve.invert();
|
||||
}
|
||||
curve
|
||||
}
|
||||
@@ -28,6 +28,7 @@ mod coplanar;
|
||||
mod curveblend;
|
||||
mod delete_face;
|
||||
mod emboss;
|
||||
mod face_params;
|
||||
#[cfg(test)]
|
||||
mod face_source_tests;
|
||||
mod fill;
|
||||
@@ -92,6 +93,7 @@ mod splitface;
|
||||
mod step;
|
||||
mod step_in;
|
||||
mod step_out;
|
||||
mod surface_extend;
|
||||
mod surface_kind;
|
||||
mod surface_offset;
|
||||
mod surface_ruled;
|
||||
@@ -137,6 +139,7 @@ pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map};
|
||||
pub use step::step_export;
|
||||
pub use step_in::{ImportedBody, StepImport, StepNode, step_cache_alive, step_import, step_import_shared, step_orientation_errors, step_validate};
|
||||
pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products};
|
||||
pub use surface_extend::{ExtendLimit, ExtendMode, ExtendOptions, extend_surface};
|
||||
pub use surface_kind::{FaceSurface, face_surface};
|
||||
pub use surface_offset::offset_surface;
|
||||
pub use surface_ruled::{Ruled, RuledCorners, RuledMode, ruled_surface};
|
||||
|
||||
@@ -903,9 +903,25 @@ pub(crate) fn analytic_field(s: &mt::Surface) -> Option<Analytic> {
|
||||
|
||||
/// Which side of a tool a point is on.
|
||||
pub(crate) fn side_of_point(tool: &SplitTool, p: Vec3) -> Result<f64> {
|
||||
let f = fields(tool)?;
|
||||
let [g] = f.as_slice() else { return Err(KernelError::Invalid("one tool at a time".into())) };
|
||||
Ok(g.eval(p))
|
||||
Ok(ToolSide::of(tool)?.at(p))
|
||||
}
|
||||
|
||||
/// Which side of a tool points are on, the tool's field built once for many points.
|
||||
pub(crate) struct ToolSide(Field);
|
||||
|
||||
impl ToolSide {
|
||||
pub(crate) fn of(tool: &SplitTool) -> Result<ToolSide> {
|
||||
let mut f = fields(tool)?;
|
||||
match (f.pop(), f.is_empty()) {
|
||||
(Some(g), true) => Ok(ToolSide(g)),
|
||||
_ => Err(KernelError::Invalid("one tool at a time".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The side of `p`: its sign says which, zero on the tool (NaN where it can't be told).
|
||||
pub(crate) fn at(&self, p: Vec3) -> f64 {
|
||||
self.0.eval(p)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which side of a tool a face lies on (its field well inside it).
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,26 +6,18 @@
|
||||
//! boundary runs along the surface's parameter lines, each edge exact: a line, an arc about a
|
||||
//! revolved surface's axis, a rotated profile, or a B-spline iso-curve.
|
||||
//!
|
||||
//! Which loop is the outer one is worked out from the face's parameters (the loop enclosing the
|
||||
//! most area), never from the order the loops are stored in: an imported face can list its holes
|
||||
//! first.
|
||||
//! Which loop is the outer one is worked out from the face's parameters ([`FaceLoops`]).
|
||||
|
||||
use mt::{BoundedCurve, Cut, InnerSpace, Invertible, MetricSpace, ParametricCurve, ParametricSurface, SearchNearestParameter, Transformed};
|
||||
use mt::{BoundedCurve, MetricSpace, ParametricCurve, ParametricSurface};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::tess::EvalSurface;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, from_p3};
|
||||
use crate::body::Body;
|
||||
use crate::face_params::{FaceLoops, iso_curve, natural_span, passes};
|
||||
use crate::record::{Rebuild, rebuilt_body};
|
||||
use crate::surfaces::surface_body;
|
||||
use crate::tess::uv::{self, Domain};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
/// Samples along each boundary edge when working out the face's parameter loops.
|
||||
const EDGE_SAMPLES: usize = 32;
|
||||
/// Golden-section steps that place an extreme of the face's span on an edge.
|
||||
const EXTREME_STEPS: usize = 60;
|
||||
|
||||
/// The inner loops Untrim takes away.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum UntrimHoles<'a> {
|
||||
@@ -65,86 +57,11 @@ pub fn untrim(body: &Body, face: usize, holes: UntrimHoles, outer: bool, in_plac
|
||||
Ok(rebuilt_body(result, &[&body.solid], &rec))
|
||||
}
|
||||
|
||||
/// A boundary loop of a face sampled in 3D and in the surface's parameters: for each of its
|
||||
/// edges (as the loop runs) the curve parameters, points and parameters of its samples, ends
|
||||
/// included. Periodic parameters are unwrapped along the loop.
|
||||
struct LoopSamples {
|
||||
edges: Vec<(mt::Curve, Vec<(f64, Vec3, [f64; 2])>)>,
|
||||
}
|
||||
|
||||
impl LoopSamples {
|
||||
/// The loop as a polygon in parameters (each edge's samples but its last, which the next
|
||||
/// edge starts on).
|
||||
fn polygon(&self) -> Vec<[f64; 2]> {
|
||||
self.edges.iter().flat_map(|(_, s)| s.iter().take(s.len().saturating_sub(1)).map(|x| x.2)).collect()
|
||||
}
|
||||
|
||||
/// Signed area of the loop in parameters (positive: counter-clockwise).
|
||||
fn area(&self) -> f64 {
|
||||
let poly = self.polygon();
|
||||
let n = poly.len();
|
||||
(0..n)
|
||||
.filter_map(|i| {
|
||||
let (a, b) = (poly.get(i)?, poly.get((i + 1) % n)?);
|
||||
Some(0.5 * (a[0] * b[1] - b[0] * a[1]))
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// How far `p` is from the loop (from its samples).
|
||||
fn dist(&self, p: Vec3) -> f64 {
|
||||
self.edges.iter().flat_map(|(_, s)| s.iter().map(|x| x.1.dist(p))).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample a boundary loop (absolute: as it runs on the surface) in the surface's parameters.
|
||||
fn sample_loop(s: &EvalSurface, dom: &Domain, wire: &mt::Wire, accept: f64) -> Result<LoopSamples> {
|
||||
let mut edges = Vec::new();
|
||||
let mut prev: Option<[f64; 2]> = None;
|
||||
for e in wire.edge_iter() {
|
||||
let c = e.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let mut samples = Vec::with_capacity(EDGE_SAMPLES + 1);
|
||||
for k in 0..=EDGE_SAMPLES {
|
||||
let t = t0 + (t1 - t0) * k as f64 / EDGE_SAMPLES as f64;
|
||||
let p = from_p3(c.subs(t));
|
||||
let q = parameters(s, dom, p, prev, accept)?;
|
||||
samples.push((t, p, q));
|
||||
prev = Some(q);
|
||||
}
|
||||
edges.push((c, samples));
|
||||
}
|
||||
Ok(LoopSamples { edges })
|
||||
}
|
||||
|
||||
/// The parameters of `p` on the surface, unwrapped to lie near `near` along periodic directions.
|
||||
fn parameters(s: &EvalSurface, dom: &Domain, p: Vec3, near: Option<[f64; 2]>, accept: f64) -> Result<[f64; 2]> {
|
||||
let mut q = uv::project(s, dom, p, near, accept).ok_or_else(|| KernelError::Failed("the face's boundary does not lie on its surface".into()))?;
|
||||
if let Some(n) = near {
|
||||
for d in 0..2 {
|
||||
q[d] = uv::unwrap(q[d], n[d], dom.period[d]);
|
||||
}
|
||||
}
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
/// The face untrimmed (see [`untrim`]).
|
||||
fn untrimmed_face(face: &mt::Face, holes: UntrimHoles, outer: bool, size: f64) -> Result<mt::Face> {
|
||||
let surface = face.surface();
|
||||
let eval =
|
||||
crate::tess::surface::eval_surface(&surface).ok_or_else(|| KernelError::TooLarge("untrimming a face on this kind of surface".into()))?;
|
||||
let dom = uv::domain(&eval);
|
||||
let accept = (size * 1e-5).max(1e-6);
|
||||
let wires = face.absolute_boundaries();
|
||||
let loops: Vec<LoopSamples> = wires.iter().map(|w| sample_loop(&eval, &dom, w, accept)).collect::<Result<_>>()?;
|
||||
// The outer loop encloses the others: the most area in parameters.
|
||||
let areas: Vec<f64> = loops.iter().map(LoopSamples::area).collect();
|
||||
let outer_index = areas
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
|
||||
.map(|(i, _)| i)
|
||||
.ok_or_else(|| KernelError::Invalid("the face has no boundary".into()))?;
|
||||
let FaceLoops { eval, dom, wires, loops, outer: outer_index } = FaceLoops::of(face, accept, "untrimming")?;
|
||||
let inner: Vec<usize> = (0..wires.len()).filter(|i| *i != outer_index).collect();
|
||||
let removed: Vec<usize> = match holes {
|
||||
UntrimHoles::All => inner.clone(),
|
||||
@@ -173,13 +90,13 @@ fn untrimmed_face(face: &mt::Face, holes: UntrimHoles, outer: bool, size: f64) -
|
||||
"nothing to untrim: pick holes to remove or restore the outer boundary".into()
|
||||
}));
|
||||
}
|
||||
let (Some(outer_wire), Some(outer_loop), Some(outer_area)) = (wires.get(outer_index), loops.get(outer_index), areas.get(outer_index)) else {
|
||||
let (Some(outer_wire), Some(outer_loop)) = (wires.get(outer_index), loops.get(outer_index)) else {
|
||||
return Err(KernelError::Invalid("the face has no boundary".into()));
|
||||
};
|
||||
let first = if outer {
|
||||
let analytic = crate::offset::surf_of(face, (size * 1e-7).max(1e-9) * 100.0).is_some();
|
||||
let span = natural_span(&eval, &dom, outer_loop, analytic, accept)?;
|
||||
bounds_wire(&surface, span, *outer_area > 0.0, size)?
|
||||
bounds_wire(&surface, span, outer_loop.area() > 0.0, size)?
|
||||
} else {
|
||||
outer_wire.clone()
|
||||
};
|
||||
@@ -192,76 +109,6 @@ fn untrimmed_face(face: &mt::Face, holes: UntrimHoles, outer: bool, size: f64) -
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// The parameter box the outer boundary is restored to: along each direction the surface's own
|
||||
/// range where it has one of its own (a free-form surface's; not a periodic or unbounded
|
||||
/// direction, and not an analytic surface's), else the face's own span.
|
||||
fn natural_span(s: &EvalSurface, dom: &Domain, outer: &LoopSamples, analytic: bool, accept: f64) -> Result<[(f64, f64); 2]> {
|
||||
let mut span = [(0.0, 0.0); 2];
|
||||
for (d, out) in span.iter_mut().enumerate() {
|
||||
*out = match (dom.range.get(d).copied().flatten(), dom.period.get(d).copied().flatten()) {
|
||||
(Some(range), None) if !analytic => range,
|
||||
(_, period) => {
|
||||
let lo = extreme(s, dom, outer, d, false, accept);
|
||||
let hi = extreme(s, dom, outer, d, true, accept);
|
||||
if let Some(p) = period
|
||||
&& hi - lo >= p * (1.0 - 1e-9)
|
||||
{
|
||||
return Err(KernelError::TooLarge("a face that goes all the way round its surface".into()));
|
||||
}
|
||||
(lo, hi)
|
||||
}
|
||||
};
|
||||
if !(out.1 - out.0 > 1e-12 * (1.0 + out.0.abs() + out.1.abs())) || !out.0.is_finite() || !out.1.is_finite() {
|
||||
return Err(KernelError::Invalid("the face has no extent along its surface".into()));
|
||||
}
|
||||
}
|
||||
Ok(span)
|
||||
}
|
||||
|
||||
/// The least (`max == false`) or greatest parameter along direction `d` the loop reaches: the
|
||||
/// extreme sample, then placed on its edge between the neighbouring samples. Samples where the
|
||||
/// surface does not move along `d` (a pole) say nothing about `d` and are passed over.
|
||||
fn extreme(s: &EvalSurface, dom: &Domain, lp: &LoopSamples, d: usize, max: bool, accept: f64) -> f64 {
|
||||
let sign = if max { 1.0 } else { -1.0 };
|
||||
let mut best: Option<(f64, usize, usize)> = None;
|
||||
for (ei, (_, samples)) in lp.edges.iter().enumerate() {
|
||||
for (k, (_, _, q)) in samples.iter().enumerate() {
|
||||
if uv::degenerate(s, *q, d) {
|
||||
continue;
|
||||
}
|
||||
let x = sign * q[d];
|
||||
if best.is_none_or(|(b, _, _)| x > b) {
|
||||
best = Some((x, ei, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((x, ei, k)) = best else { return f64::NAN };
|
||||
let Some((curve, samples)) = lp.edges.get(ei) else { return sign * x };
|
||||
let (Some(at), Some(lo), Some(hi)) = (samples.get(k), samples.get(k.saturating_sub(1)), samples.get(k + 1).or(samples.get(k))) else {
|
||||
return sign * x;
|
||||
};
|
||||
// Golden-section search for the extreme between the neighbouring samples.
|
||||
let value = |t: f64| -> Option<f64> {
|
||||
let p = from_p3(curve.subs(t));
|
||||
let q = uv::project(s, dom, p, Some(at.2), accept)?;
|
||||
Some(sign * uv::unwrap(q[d], at.2[d], dom.period[d]))
|
||||
};
|
||||
let g = (5f64.sqrt() - 1.0) / 2.0;
|
||||
let (mut a, mut b) = (lo.0, hi.0);
|
||||
let mut best = x;
|
||||
for _ in 0..EXTREME_STEPS {
|
||||
let (c, e) = (b - g * (b - a), a + g * (b - a));
|
||||
let (Some(fc), Some(fe)) = (value(c), value(e)) else { break };
|
||||
best = best.max(fc).max(fe);
|
||||
if fc > fe {
|
||||
b = e;
|
||||
} else {
|
||||
a = c;
|
||||
}
|
||||
}
|
||||
sign * best
|
||||
}
|
||||
|
||||
/// The closed boundary of the parameter box `span` on `surface`, counter-clockwise in its
|
||||
/// parameters when `ccw`: four parameter-line edges, fewer where a side shrinks to a point (a
|
||||
/// pole: the corners at its ends are one vertex).
|
||||
@@ -313,93 +160,6 @@ fn bounds_wire(surface: &mt::Surface, span: [(f64, f64); 2], ccw: bool, size: f6
|
||||
Ok(edges.into())
|
||||
}
|
||||
|
||||
/// Whether the curve passes within `tol` of `p`.
|
||||
fn passes(curve: &mt::Curve, p: mt::Point3, tol: f64) -> bool {
|
||||
let (t0, t1) = curve.range_tuple();
|
||||
let start = (0..=64)
|
||||
.map(|k| t0 + (t1 - t0) * k as f64 / 64.0)
|
||||
.min_by(|a, b| curve.subs(*a).distance(p).total_cmp(&curve.subs(*b).distance(p)))
|
||||
.unwrap_or(t0);
|
||||
let t = curve.search_nearest_parameter(p, Some(start), 100).unwrap_or(start).clamp(t0.min(t1), t0.max(t1));
|
||||
curve.subs(t).distance(p) <= tol
|
||||
}
|
||||
|
||||
/// The parameter line of `surface` at `w` (v = w when `along_u`, else u = w) from parameter
|
||||
/// `from` to `to` along it, exact: a line on a plane, a B-spline surface's iso-curve, and on a
|
||||
/// revolved surface the profile turned to its angle or an arc about the axis.
|
||||
fn iso_curve(surface: &mt::Surface, along_u: bool, w: f64, from: f64, to: f64) -> Result<mt::Curve> {
|
||||
let at = |t: f64| if along_u { surface.subs(t, w) } else { surface.subs(w, t) };
|
||||
match surface {
|
||||
mt::Surface::Plane(_) => Ok(mt::Curve::Line(mt::Line(at(from), at(to)))),
|
||||
mt::Surface::BSplineSurface(_) | mt::Surface::NurbsSurface(_) => {
|
||||
let hom: mt::BSplineSurface<mt::Vector4> = match surface {
|
||||
mt::Surface::BSplineSurface(b) => mt::BSplineSurface::new(
|
||||
b.knot_vecs().clone(),
|
||||
b.control_points().iter().map(|row| row.iter().map(|p| p.to_homogeneous()).collect()).collect(),
|
||||
),
|
||||
mt::Surface::NurbsSurface(n) => n.non_rationalized().clone(),
|
||||
_ => return Err(KernelError::Failed("not a B-spline surface".into())),
|
||||
};
|
||||
let iso = crate::step_in::split::iso_bsp(&hom, w, along_u).map_err(KernelError::Failed)?;
|
||||
Ok(piece(mt::Curve::NurbsCurve(iso), from, to))
|
||||
}
|
||||
mt::Surface::RevolutedCurve(proc) => {
|
||||
let rev = proc.entity();
|
||||
// Truck's revolved surface is (profile parameter, angle), swapped when the processor
|
||||
// is turned over; the processor's matrix places it.
|
||||
let along_profile = along_u == proc.orientation();
|
||||
let transform = *proc.transform();
|
||||
let (origin, axis) = (rev.origin(), rev.axis().normalize());
|
||||
if along_profile {
|
||||
let profile = rev.entity_curve();
|
||||
if let mt::Curve::Line(_) = profile {
|
||||
return Ok(mt::Curve::Line(mt::Line(at(from), at(to))));
|
||||
}
|
||||
if let mt::Curve::IntersectionCurve(_) = profile {
|
||||
return Err(KernelError::TooLarge("untrimming a surface revolved from an intersection curve".into()));
|
||||
}
|
||||
let turn = mt::Matrix4::from_translation(origin - mt::Point3::new(0.0, 0.0, 0.0))
|
||||
* mt::Matrix4::from_axis_angle(axis, mt::Rad(w))
|
||||
* mt::Matrix4::from_translation(mt::Point3::new(0.0, 0.0, 0.0) - origin);
|
||||
Ok(piece(profile.transformed(transform * turn), from, to))
|
||||
} else {
|
||||
// The profile point at `w` turns about the axis: an arc from angle `from` to `to`.
|
||||
let p = rev.entity_curve().subs(w);
|
||||
let centre = origin + axis * (p - origin).dot(axis);
|
||||
let r = (p - centre).magnitude();
|
||||
if !(r > 1e-12) {
|
||||
return Err(KernelError::Failed("an arc on the axis of a revolved surface".into()));
|
||||
}
|
||||
let x = (p - centre) / r;
|
||||
let frame = crate::step_in::geom::Frame { o: centre, x, y: axis.cross(x), z: axis };
|
||||
let arc = if to > from {
|
||||
crate::step_in::geom::conic_arc(&frame, r, r, from, to - from)
|
||||
} else {
|
||||
crate::step_in::geom::conic_arc(&frame, r, r, to, from - to).inverse()
|
||||
};
|
||||
Ok(mt::Curve::NurbsCurve(arc).transformed(transform))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The piece of `curve` between its parameters `from` and `to`, running from `from`.
|
||||
fn piece(mut curve: mt::Curve, from: f64, to: f64) -> mt::Curve {
|
||||
let (lo, hi) = if from < to { (from, to) } else { (to, from) };
|
||||
let (t0, t1) = curve.range_tuple();
|
||||
let eps = (t1 - t0).abs() * 1e-12;
|
||||
if lo > t0 + eps {
|
||||
curve = curve.cut(lo);
|
||||
}
|
||||
if hi < t1 - eps {
|
||||
let _ = curve.cut(hi);
|
||||
}
|
||||
if from > to {
|
||||
curve.invert();
|
||||
}
|
||||
curve
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -661,8 +661,33 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
|
||||
Surf::Thicken { thickness: thickness.clone(), symmetric: *symmetric, operation: op_index(operation) },
|
||||
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
|
||||
),
|
||||
FeatureKind::SurfaceExtend { edges, distance, .. } => {
|
||||
(Surf::Extend { distance: distance.clone() }, vec![edges.iter().filter_map(edge).collect()])
|
||||
FeatureKind::SurfaceExtend { edges, distance, mode, up_to, neighbours, .. } => {
|
||||
// The dialog edits the edges and the distance; the rest is kept as it was.
|
||||
let mut extra = Map::new();
|
||||
if !mode.is_natural() {
|
||||
extra.insert("mode".into(), json!(mode));
|
||||
}
|
||||
if !*neighbours {
|
||||
extra.insert("neighbours".into(), json!(false));
|
||||
}
|
||||
match up_to {
|
||||
Some(solidcraft_engine::doc::ExtendTo::Bounds) => {
|
||||
extra.insert("up_to".into(), json!("bounds"));
|
||||
}
|
||||
Some(solidcraft_engine::doc::ExtendTo::Face(t)) => {
|
||||
extra.insert("up_to".into(), json!({"body": t.body, "point": pt(t.point)}));
|
||||
}
|
||||
Some(solidcraft_engine::doc::ExtendTo::Plane(PlaneRef::Origin { name } | PlaneRef::Construction { name })) => {
|
||||
extra.insert("up_to".into(), json!(name));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let k = Surf::Extend { distance: distance.clone() };
|
||||
let mut ins = inputs(&k);
|
||||
if let Some(inp) = ins.first_mut() {
|
||||
inp.items = edges.iter().filter_map(edge).collect();
|
||||
}
|
||||
return Some((Kind::Surface(k), ins, extra));
|
||||
}
|
||||
FeatureKind::SurfaceTrim { body, plane, tool, keep } => (
|
||||
Surf::Trim,
|
||||
|
||||
Reference in new issue
Block a user