Class-A surfacing 2/3: surface match, boundary patch and CV fairing (#1487)
Thanks @midasdf.
This commit is contained in:
1 parent
7f79443b46
commit
07da7f8065
27 files changed
+3513
-11
No files matched your search
@@ -36,6 +36,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/class-a-boundary.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch through two sketch lines built by `surface.boundary` with `crown: 0.4` (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -366,8 +366,10 @@
|
||||
"cmd.surface_blend": "Verbindungsfläche",
|
||||
"cmd.surface_boundary": "Begrenzungsfläche",
|
||||
"cmd.surface_extrude": "Flächen-Extrusion",
|
||||
"cmd.surface_fair": "Flächenbegradigung",
|
||||
"cmd.surface_fillet": "Flächenverrundung",
|
||||
"cmd.surface_loft": "Flächen-Loft",
|
||||
"cmd.surface_match": "Flächenanpassung",
|
||||
"cmd.surface_offset": "Offsetfläche",
|
||||
"cmd.surface_patch": "Patch",
|
||||
"cmd.surface_reverse_normal": "Normale umkehren",
|
||||
|
||||
@@ -568,8 +568,10 @@
|
||||
"cmd.surface_blend": "Blend Surface",
|
||||
"cmd.surface_boundary": "Boundary Surface",
|
||||
"cmd.surface_extrude": "Surface Extrude",
|
||||
"cmd.surface_fair": "Fair Surface",
|
||||
"cmd.surface_fillet": "Surface Fillet",
|
||||
"cmd.surface_loft": "Surface Loft",
|
||||
"cmd.surface_match": "Match Surface",
|
||||
"cmd.surface_offset": "Offset Surface",
|
||||
"cmd.surface_patch": "Patch",
|
||||
"cmd.surface_reverse_normal": "Reverse Normal",
|
||||
|
||||
@@ -568,8 +568,10 @@
|
||||
"cmd.surface_blend": "ブレンドサーフェス",
|
||||
"cmd.surface_boundary": "境界サーフェス",
|
||||
"cmd.surface_extrude": "サーフェス押し出し",
|
||||
"cmd.surface_fair": "サーフェスフェアリング",
|
||||
"cmd.surface_fillet": "サーフェスフィレット",
|
||||
"cmd.surface_loft": "サーフェスロフト",
|
||||
"cmd.surface_match": "サーフェス一致",
|
||||
"cmd.surface_offset": "オフセットサーフェス",
|
||||
"cmd.surface_patch": "パッチ",
|
||||
"cmd.surface_reverse_normal": "法線を反転",
|
||||
|
||||
@@ -366,8 +366,10 @@
|
||||
"cmd.surface_blend": "블렌드 서피스",
|
||||
"cmd.surface_boundary": "바운더리 서피스",
|
||||
"cmd.surface_extrude": "서피스 돌출",
|
||||
"cmd.surface_fair": "서피스 페어링",
|
||||
"cmd.surface_fillet": "서피스 필렛",
|
||||
"cmd.surface_loft": "서피스 로프트",
|
||||
"cmd.surface_match": "서피스 일치",
|
||||
"cmd.surface_offset": "오프셋 서피스",
|
||||
"cmd.surface_patch": "패치",
|
||||
"cmd.surface_reverse_normal": "법선 반전",
|
||||
|
||||
@@ -568,8 +568,10 @@
|
||||
"cmd.surface_blend": "混合曲面",
|
||||
"cmd.surface_boundary": "边界曲面",
|
||||
"cmd.surface_extrude": "曲面拉伸",
|
||||
"cmd.surface_fair": "曲面光顺",
|
||||
"cmd.surface_fillet": "曲面圆角",
|
||||
"cmd.surface_loft": "曲面放样",
|
||||
"cmd.surface_match": "曲面匹配",
|
||||
"cmd.surface_offset": "偏移曲面",
|
||||
"cmd.surface_patch": "补面",
|
||||
"cmd.surface_reverse_normal": "反转法向",
|
||||
|
||||
@@ -1384,6 +1384,59 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
stitch: bool,
|
||||
},
|
||||
/// One surface patch through a network of two to four boundary curves of a sketch
|
||||
/// (`surface.boundary`, issue #763): the patch runs through the curves in loop order and leaves
|
||||
/// each boundary at its own `order` — a sketch curve's boundary is a plane, so G1 and up hold
|
||||
/// exactly — with `crown` shaping the middle without touching a boundary.
|
||||
SurfaceBoundary {
|
||||
sketch: SketchId,
|
||||
/// The curves in loop order: each one ends where the next begins, and the last where the
|
||||
/// first does.
|
||||
curves: Vec<String>,
|
||||
/// How much of the surface across each boundary the patch repeats, one per curve (none: a
|
||||
/// tangent plane, `g1`, on every side).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
orders: Vec<Continuity>,
|
||||
/// How full the middle is: 0 the plain construction, up to 3 fuller, down to -0.9 flatter.
|
||||
#[serde(default)]
|
||||
crown: f64,
|
||||
/// The stations every boundary carries.
|
||||
#[serde(default = "boundary_samples")]
|
||||
samples: usize,
|
||||
},
|
||||
/// One face of a surface body rebuilt against another body (`surface.match`): the rebuilt
|
||||
/// boundary nearest the chain of `edges` on `target` takes that chain's place and repeats the
|
||||
/// target's own shape along the seam up to `order`, while the boundary opposite it, and the
|
||||
/// body's name, stay where they were. `faces` picks the face to rebuild (its first, when none).
|
||||
SurfaceMatch {
|
||||
body: BodyName,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
faces: Vec<FacePick>,
|
||||
target: BodyName,
|
||||
edges: Vec<Position>,
|
||||
#[serde(default = "match_order")]
|
||||
order: Continuity,
|
||||
},
|
||||
/// The B-spline faces at `faces` of `body` faired (`surface.fair`): each face's interior
|
||||
/// control vertices move to flatten the thin-plate (curvature-variation) energy of the face,
|
||||
/// `strength` of the full least-squares step at a time, for at most `iterations` steps.
|
||||
/// `keep_edges` holds the row of control points next to each edge as well as the boundary row
|
||||
/// itself, so the face keeps meeting its neighbours in position and in tangent. `tolerance`
|
||||
/// is the farthest a control point may move, so the faired face stays within it of the one the
|
||||
/// design asked for; an empty `faces` fairs every face of the body.
|
||||
SurfaceFair {
|
||||
body: BodyName,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
faces: Vec<FacePick>,
|
||||
#[serde(default = "fair_strength")]
|
||||
strength: f64,
|
||||
#[serde(default = "fair_iterations")]
|
||||
iterations: u32,
|
||||
#[serde(default = "fair_keep_edges")]
|
||||
keep_edges: bool,
|
||||
#[walk(length = "Tolerance")]
|
||||
tolerance: Expr,
|
||||
},
|
||||
/// A surface body trimmed by a plane, or by a face of a body (`tool`), keeping the side of
|
||||
/// `keep`.
|
||||
SurfaceTrim {
|
||||
@@ -1600,10 +1653,33 @@ fn is_g0(g: &Continuity) -> bool {
|
||||
}
|
||||
|
||||
/// The continuity a blend repeats by default: the tangent plane on both sides.
|
||||
/// The stations a boundary patch carries by default.
|
||||
fn boundary_samples() -> usize {
|
||||
25
|
||||
}
|
||||
|
||||
fn blend_orders() -> [Continuity; 2] {
|
||||
[Continuity::G1, Continuity::G1]
|
||||
}
|
||||
|
||||
/// The continuity a match holds by default: position, tangent plane and curvature.
|
||||
fn match_order() -> Continuity {
|
||||
Continuity::G2
|
||||
}
|
||||
|
||||
/// A fairing's defaults for a design that leaves them out: the whole least-squares step at a
|
||||
/// time, and the boundary held (the row next to each edge too, so the face still meets its
|
||||
/// neighbours in tangent).
|
||||
fn fair_strength() -> f64 {
|
||||
1.0
|
||||
}
|
||||
fn fair_iterations() -> u32 {
|
||||
1
|
||||
}
|
||||
fn fair_keep_edges() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Triangles of one imported mesh body (millimetres).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MeshData {
|
||||
@@ -1896,6 +1972,9 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceSweep { .. } => "SurfaceSweepFeature",
|
||||
FeatureKind::SurfaceLoft { .. } => "SurfaceLoftFeature",
|
||||
FeatureKind::SurfaceBlend { .. } => "BlendFeature",
|
||||
FeatureKind::SurfaceBoundary { .. } => "BoundaryFeature",
|
||||
FeatureKind::SurfaceMatch { .. } => "MatchFeature",
|
||||
FeatureKind::SurfaceFair { .. } => "FairFeature",
|
||||
FeatureKind::Stitch { .. } => "StitchFeature",
|
||||
FeatureKind::Thicken { .. } => "ThickenFeature",
|
||||
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
|
||||
@@ -1973,6 +2052,9 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceSweep { .. } => "SurfaceSweep",
|
||||
FeatureKind::SurfaceLoft { .. } => "SurfaceLoft",
|
||||
FeatureKind::SurfaceBlend { .. } => "Blend",
|
||||
FeatureKind::SurfaceBoundary { .. } => "Boundary",
|
||||
FeatureKind::SurfaceMatch { .. } => "Match",
|
||||
FeatureKind::SurfaceFair { .. } => "Fair",
|
||||
FeatureKind::Stitch { .. } => "Stitch",
|
||||
FeatureKind::Thicken { .. } => "Thicken",
|
||||
FeatureKind::SurfaceTrim { .. } => "Trim",
|
||||
|
||||
@@ -4133,6 +4133,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::SurfaceSweep { .. }
|
||||
| FeatureKind::SurfaceLoft { .. }
|
||||
| FeatureKind::SurfaceBlend { .. }
|
||||
| FeatureKind::SurfaceBoundary { .. }
|
||||
| FeatureKind::SurfaceMatch { .. }
|
||||
| FeatureKind::SurfaceFair { .. }
|
||||
| FeatureKind::SurfaceOffset { .. }
|
||||
| FeatureKind::SurfaceRuled { .. }
|
||||
| FeatureKind::SurfaceUntrim { .. } => surface::eval(doc, vals, f, st, warning),
|
||||
|
||||
@@ -145,6 +145,78 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
st.bodies.push(ModelBody::new(name, made, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceBoundary { sketch, curves, orders, crown, samples } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
doc,
|
||||
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
)?;
|
||||
let boundaries = boundary_frames(&ss, curves, orders, *samples)?;
|
||||
let body = kernel::boundary_patch(&boundaries, *crown, *samples)?;
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::new(name, body, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceMatch { body, faces, target, edges, order } => {
|
||||
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 tb = st.body(target).ok_or_else(|| DocError::Unknown(format!("body `{target}`")))?.body.clone();
|
||||
if edges.is_empty() {
|
||||
return Err(DocError::Invalid("a match needs a chain of the target's edges".into()));
|
||||
}
|
||||
// Picked by a point on the face; without picks, the body's first face.
|
||||
let picked: Vec<solidcraft_geom::Vec3> = if faces.is_empty() {
|
||||
let faces = mb.body.faces(mb.body.size() * 1e-2)?;
|
||||
vec![faces.first().ok_or_else(|| DocError::Invalid(format!("`{body}` has no face to match")))?.centroid]
|
||||
} else {
|
||||
crate::plain(faces)
|
||||
};
|
||||
let made = kernel::match_surface(&mb.body, &picked, &tb, &crate::plain(edges), *order)?;
|
||||
// A match rebuilds the face in place: the body keeps its name, and whatever meets it
|
||||
// downstream still finds it.
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, made, mb.feature);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceFair { body, faces, strength, iterations, keep_edges, tolerance } => {
|
||||
let tol = val(vals, tolerance, Kind::Length)?;
|
||||
let opts = kernel::FairOptions::new(*strength, *iterations as usize, *keep_edges, tol)?;
|
||||
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())) };
|
||||
// Picked by a point on the face; without picks, every face of the body.
|
||||
let mut picked: Vec<usize> = Vec::new();
|
||||
if faces.is_empty() {
|
||||
picked.extend(0..mb.body.face_count());
|
||||
} else {
|
||||
for p in faces {
|
||||
let fi =
|
||||
crate::appearance::face_index_at(&mb, p.0).ok_or_else(|| DocError::Invalid(format!("Fair: no face of {body} at {p:?}")))?;
|
||||
if !picked.contains(&fi) {
|
||||
picked.push(fi);
|
||||
}
|
||||
}
|
||||
}
|
||||
if picked.is_empty() {
|
||||
return Err(DocError::Invalid("the body has no faces to fair".into()));
|
||||
}
|
||||
// The face keeps every edge it had: only the surface between them is faired. The
|
||||
// energy before and after goes into the feature's warning, so the reply can say how
|
||||
// much the face moved.
|
||||
let (faired, report) = kernel::fair_faces(&mb.body, &picked, &opts)?;
|
||||
*warning = Some(format!(
|
||||
"faired {} face(s): energy {:.6} → {:.6} in {} step(s), control points moved up to {:.4} mm",
|
||||
picked.len(),
|
||||
report.energy_before,
|
||||
report.energy_after,
|
||||
report.steps,
|
||||
report.movement
|
||||
));
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, faired, mb.feature);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
doc,
|
||||
@@ -349,6 +421,70 @@ fn push_sheet(st: &mut ModelState, f: &Feature, traced: crate::lineage::Traced)
|
||||
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
|
||||
}
|
||||
|
||||
/// The boundaries `surface.boundary` builds its patch from: each picked curve's own stations in
|
||||
/// the sketch's frame, every one of them leaving the boundary square into the loop — the direction
|
||||
/// from the curve towards the middle of the loop, taken across its tangent — over the plane the
|
||||
/// curve lies in (so a chain of planar curves makes a flat patch, and a network that is not
|
||||
/// coplanar is followed as the plane through the curve's own tangent). `orders` sets how much of
|
||||
/// that plane each side repeats.
|
||||
fn boundary_frames(
|
||||
ss: &SolvedSketch,
|
||||
curves: &[String],
|
||||
orders: &[solidcraft_kernel::Continuity],
|
||||
samples: usize,
|
||||
) -> Result<Vec<solidcraft_kernel::Boundary>> {
|
||||
use solidcraft_kernel::{Boundary, Frame};
|
||||
// The stations along each curve, evenly in its own arclength, so every side carries the same
|
||||
// number of frames and the patch's net is square.
|
||||
let mut sides: Vec<Vec<solidcraft_geom::Vec3>> = Vec::with_capacity(curves.len());
|
||||
for id in curves {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("sketch curve `{id}`")))?;
|
||||
let pieces = ss.sketch.exact_pieces(ci);
|
||||
if pieces.is_empty() {
|
||||
return Err(DocError::Invalid(format!("curve `{id}` has no exact geometry to build on")));
|
||||
}
|
||||
let lengths: Vec<f64> = pieces.iter().map(Seg2::length).collect();
|
||||
let total: f64 = lengths.iter().sum();
|
||||
if !total.is_finite() || total <= 1e-9 {
|
||||
return Err(DocError::Invalid(format!("curve `{id}` has no length")));
|
||||
}
|
||||
let mut pts = Vec::with_capacity(samples);
|
||||
let (mut used, mut at) = (0.0, 0usize);
|
||||
for k in 0..samples {
|
||||
let want = total * k as f64 / (samples.saturating_sub(1)).max(1) as f64;
|
||||
while at + 1 < pieces.len() && used + lengths[at] < want {
|
||||
used += lengths[at];
|
||||
at += 1;
|
||||
}
|
||||
let (Some(piece), Some(len)) = (pieces.get(at), lengths.get(at)) else { return Err(DocError::Invalid("boundary piece".into())) };
|
||||
let t = ((want - used) / len.max(1e-12)).clamp(0.0, 1.0);
|
||||
pts.push(ss.plane.to_world(piece.point_at(t)));
|
||||
}
|
||||
sides.push(pts);
|
||||
}
|
||||
// The middle of the loop, so every side leaves towards the inside of the patch.
|
||||
let count = sides.iter().map(Vec::len).sum::<usize>().max(1) as f64;
|
||||
let centre = sides.iter().flatten().fold(solidcraft_geom::Vec3::ZERO, |a, p| a + *p) * (1.0 / count);
|
||||
let mut out = Vec::with_capacity(sides.len());
|
||||
for (side, pts) in sides.iter().enumerate() {
|
||||
let mut frames = Vec::with_capacity(pts.len());
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
// The tangent from the stations on either side, one-sided at the ends (where the side
|
||||
// only has one neighbour).
|
||||
let before = if i == 0 { *p } else { pts[i - 1] };
|
||||
let after = if i + 1 == pts.len() { *p } else { pts[i + 1] };
|
||||
let tangent = (after - before).normalized().ok_or_else(|| DocError::Invalid("a boundary curve has a stationary station".into()))?;
|
||||
let across = centre - *p;
|
||||
let leave = (across - tangent * across.dot(tangent))
|
||||
.normalized()
|
||||
.ok_or_else(|| DocError::Invalid("a boundary runs through the middle of the loop: no patch can leave it".into()))?;
|
||||
frames.push(Frame { p: *p, leave, bend: Vec3::ZERO, bend_change: Vec3::ZERO });
|
||||
}
|
||||
out.push(Boundary { frames, order: orders.get(side).copied().unwrap_or(solidcraft_kernel::Continuity::G1) });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a surface builder sweeps: the boundary loops of the picked profiles, then the picked
|
||||
/// curves chained end to end.
|
||||
fn sheet_chains(ss: &SolvedSketch, profiles: Option<&ProfileSel>, curves: &[String]) -> Result<Vec<Vec<Seg2>>> {
|
||||
|
||||
@@ -161,6 +161,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
("surface_loft", json!({"type": "surface_loft", "sections": []})),
|
||||
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
|
||||
("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})),
|
||||
// The Class-A surface family (surface.blend/boundary/match/fair).
|
||||
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
|
||||
("surface_boundary", json!({"type": "surface_boundary", "sketch": 1, "curves": ["l1", "l2"]})),
|
||||
("surface_match", json!({"type": "surface_match", "body": "B", "faces": [o], "target": "C", "edges": [o]})),
|
||||
("surface_fair", json!({"type": "surface_fair", "body": "B", "faces": [o], "tolerance": "sfair.tolerance"})),
|
||||
("unstitch", json!({"type": "unstitch", "body": "B"})),
|
||||
("reverse_normal", json!({"type": "reverse_normal", "body": "B"})),
|
||||
("surface_untrim", json!({"type": "surface_untrim", "face": {"body": "B", "point": o}, "holes": [o], "outer": true})),
|
||||
|
||||
@@ -340,6 +340,13 @@ surface_offset
|
||||
Distance = soff.distance (Length)
|
||||
Tolerance = soff.tolerance (Length)
|
||||
expressions: soff.distance, soff.tolerance
|
||||
surface_blend
|
||||
surface_boundary
|
||||
surface_match
|
||||
surface_fair
|
||||
inputs:
|
||||
Tolerance = sfair.tolerance (Length)
|
||||
expressions: sfair.tolerance
|
||||
unstitch
|
||||
reverse_normal
|
||||
surface_untrim
|
||||
|
||||
@@ -154,6 +154,15 @@ surface_blend
|
||||
surface_offset
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
surface_blend
|
||||
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
|
||||
surface_boundary
|
||||
surface_match
|
||||
moves: edges[0][0], edges[0][1], edges[0][2], faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
surface_fair
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
unstitch
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
reverse_normal
|
||||
|
||||
@@ -177,6 +177,15 @@ surface_blend
|
||||
remap_bodies: from.body
|
||||
surface_offset
|
||||
remap_bodies: body
|
||||
surface_blend
|
||||
remap_bodies: from.body
|
||||
surface_boundary
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
surface_match
|
||||
remap_bodies: body
|
||||
surface_fair
|
||||
remap_bodies: body
|
||||
unstitch
|
||||
remap_bodies: body
|
||||
reverse_normal
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! The Class-A surface pass adds `SurfaceBoundary` (Boundary Surface: `sketch`, `curves`,
|
||||
//! optional `orders`, `crown`, `samples`), `SurfaceMatch` (Match Surface: `body`, optional
|
||||
//! `faces`, `target`, `edges`, optional `order`) and `SurfaceFair` (Fair Surface: `body`, optional
|
||||
//! `faces`, `strength`, `iterations`, `keep_edges`, `tolerance`). Older designs need no
|
||||
//! transformation; 0.13.x asks for an update instead of meeting a feature it can't read.
|
||||
@@ -40,6 +40,8 @@ impl Walk for bool {}
|
||||
impl Walk for u64 {}
|
||||
impl Walk for i8 {}
|
||||
impl Walk for f64 {}
|
||||
impl Walk for usize {}
|
||||
impl Walk for u32 {}
|
||||
impl Walk for ProfileSel {}
|
||||
impl Walk for solidcraft_sketch::Sketch {}
|
||||
impl Walk for solidcraft_sketch::PatternAlignment {}
|
||||
|
||||
@@ -74,6 +74,7 @@ mod sketch_modify;
|
||||
mod sketch_project;
|
||||
mod surface;
|
||||
mod surface_analysis;
|
||||
mod surface_match;
|
||||
mod view_dxf;
|
||||
mod view_navigation;
|
||||
|
||||
@@ -236,6 +237,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(sketch_3d::COMMANDS.iter());
|
||||
v.extend(surface_analysis::COMMANDS.iter());
|
||||
v.extend(surface::COMMANDS.iter());
|
||||
v.extend(surface_match::COMMANDS.iter());
|
||||
v.extend(sketch_modify::COMMANDS.iter());
|
||||
v.extend(sketch_project::COMMANDS.iter());
|
||||
v.extend(features::COMMANDS.iter());
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{
|
||||
BlendSide, EdgeContinuity, ExtendMode, ExtendTo, FaceAt, FeatureKind, PatchContinuity, PlaneRef, ProfileSel, RuledCorners, RuledMode, expr::Kind,
|
||||
BlendSide, EdgeContinuity, Expr, ExtendMode, ExtendTo, FaceAt, FeatureKind, PatchContinuity, PlaneRef, ProfileSel, RuledCorners, RuledMode,
|
||||
expr::Kind,
|
||||
};
|
||||
use solidcraft_doc::{BodyName, SketchId};
|
||||
use solidcraft_doc::{Dir, FacePick, Position};
|
||||
@@ -11,7 +12,7 @@ use solidcraft_kernel::Continuity;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, axis_param, check_expr, extent_param, feature_sketch, operation, plane_param, profiles, sketch_id};
|
||||
use crate::params::{bad, bool_, expr, str_, string_list, vec3};
|
||||
use crate::params::{bad, bool_, expr, num, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
@@ -59,10 +60,6 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("ruled")
|
||||
.params("body; edges: [[x,y,z]] (points on a chain of its edges, open or closed); distance: expr (how far the strip reaches); mode?: normal|tangent|angle|direction (default normal: along the normal of the face beside each edge; tangent: continuing it); angle?: expr (angle mode: from `direction`, else from the face's normal, turned away from the face; 90 deg from the normal is tangent); direction?: [x,y,z] (direction mode: the rulings; angle mode: the reference, e.g. a pull direction for a draft); face?: [x,y,z] (a point on the face the directions come from; default: the face beside the most picked edges); flip?: bool (the other side); corners?: mitre|round (sharp corners of the chain; inside corners are always cut back). Lines and arcs give exact planes, cylinders and cones, other edges a fitted ruled B-spline. A new surface body"),
|
||||
planned!("surface.boundary", "Boundary Surface", "763")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("boundary")
|
||||
.params("planned: u: [curves], v?: [curves] (sketch curves, 3D sketch wires or {body, point} edges; 2–4 boundaries, more make a network); continuity?: [{curve, g: 0|1|2}] for edge boundaries; operation?: new|join"),
|
||||
CommandSpec::new("surface.blend", "Blend Surface", blend)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_blend")
|
||||
@@ -79,6 +76,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("reverse_normal")
|
||||
.params("body (a surface); faces?: [[x,y,z]] (points on the faces to turn over; default: all of them)"),
|
||||
CommandSpec::new("surface.fair", "Fair Surface", fair)
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("spline_cv")
|
||||
.params("body: surface name; faces?: [[x,y,z]] points on the faces to fair (default: every face); strength?: number (0..1 of the full fairing step, default 1); iterations?: number (1..1000 steps, default 1); keep_edges?: bool (default true: hold the boundary row and the row next to it, so a face keeps meeting its neighbours in position and in tangent); tolerance?: expr mm (how far a control vertex may move, default 0.001 of the body's size). Moves the interior control vertices of each B-spline face to flatten its curvature variation while the boundary stays put: the face keeps its edges and the body its shape within the tolerance, and the warning in the reply carries the energy before and after; name?"),
|
||||
planned!("surface.fillet", "Surface Fillet", "769")
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("surface_fillet")
|
||||
@@ -558,6 +559,39 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, FeatureKind::SurfaceTrim { body: BodyName(body), plane, tool, keep: Position(keep) })
|
||||
}
|
||||
|
||||
/// `surface.fair`: the B-spline faces of a body faired in place. The tolerance a caller leaves
|
||||
/// out is a thousandth of the body's own size, so a fairing run can never make a design jump by
|
||||
/// more than a hair of itself.
|
||||
fn fair(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.fair";
|
||||
let (body, faces) = body_faces(s, p, cmd)?;
|
||||
let size = {
|
||||
let state = s.model.state();
|
||||
let Some(mb) = state.body(&body) else { return Err(bad(cmd, format!("no body `{body}`"))) };
|
||||
mb.body.size()
|
||||
};
|
||||
let strength = num(p, "strength").unwrap_or(1.0);
|
||||
if !(strength.is_finite() && strength > 0.0 && strength <= 1.0) {
|
||||
return Err(bad(cmd, "`strength` must be greater than 0 and at most 1 (the whole fairing step)"));
|
||||
}
|
||||
let steps = num(p, "iterations").unwrap_or(1.0);
|
||||
if !(steps.is_finite() && (1.0..=1000.0).contains(&steps)) {
|
||||
return Err(bad(cmd, "`iterations` must be 1 to 1000"));
|
||||
}
|
||||
let keep_edges = bool_(p, "keep_edges").unwrap_or(true);
|
||||
let tolerance = expr(p, "tolerance").unwrap_or_else(|| Expr::new(format!("{:.6}", (size * 1e-3).max(1e-6))));
|
||||
check_expr(s, tolerance.as_str(), Kind::Length, cmd, "tolerance")?;
|
||||
let kind = FeatureKind::SurfaceFair {
|
||||
body: BodyName::from(body),
|
||||
faces: faces.into_iter().map(FacePick).collect(),
|
||||
strength,
|
||||
iterations: steps as u32,
|
||||
keep_edges,
|
||||
tolerance,
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1178,7 +1212,7 @@ mod tests {
|
||||
/// as it was.
|
||||
#[test]
|
||||
fn planned_commands_refuse_and_leave_the_design_alone() {
|
||||
let planned = ["surface.boundary", "surface.fillet", "inspect.continuity"];
|
||||
let planned = ["surface.fillet", "inspect.continuity"];
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
|
||||
let before = s.doc.features.len();
|
||||
@@ -1500,4 +1534,58 @@ mod tests {
|
||||
let sketch_patch = json!({"sketch": 1, "continuity": "g1"});
|
||||
assert!(s.execute("surface.patch", &sketch_patch).is_err(), "continuity on a sketch patch");
|
||||
}
|
||||
/// The energies the reply's warning carries: the fairness energy before and after.
|
||||
fn energies(reply: &Value) -> (f64, f64) {
|
||||
let warning = reply["warning"].as_str().unwrap_or_else(|| panic!("no warning in {reply}"));
|
||||
let after = warning.split("energy ").nth(1).unwrap_or_else(|| panic!("no energies in `{warning}`"));
|
||||
let numbers: Vec<f64> = after
|
||||
.split(|c: char| !(c.is_ascii_digit() || c == '.' || c == 'e' || c == '-' || c == '+'))
|
||||
.filter_map(|w| w.parse::<f64>().ok())
|
||||
.collect();
|
||||
assert!(numbers.len() >= 2, "no energies in `{warning}`");
|
||||
(numbers[0], numbers[1])
|
||||
}
|
||||
|
||||
/// Surface Fair on the fitted B-spline face of an offset sphere: the face is smoothed in
|
||||
/// place, its edges do not move at all, the reply carries the energy before and after, undo,
|
||||
/// redo and a cold reopen keep it, and hostile inputs are refused.
|
||||
#[test]
|
||||
fn fairing_a_b_spline_face_keeps_its_edges_and_reports_its_energy() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.sphere", json!({"radius": 20, "body_name": "Ball"}));
|
||||
// A sphere is neither a plane nor a cylinder, so its offset is a fitted B-spline face.
|
||||
run(&mut s, "surface.offset", json!({"faces": [[0, 0, 20]], "body": "Ball", "distance": 2, "body_name": "Shell"}));
|
||||
let st = s.world_state();
|
||||
assert!(st.body("Shell").unwrap().body.is_surface(), "an offset sphere face is a sheet");
|
||||
let before: Vec<solidcraft_geom::Vec3> = st.body("Shell").unwrap().body.edges(0.1).unwrap().iter().flat_map(|e| e.points.clone()).collect();
|
||||
assert!(!before.is_empty());
|
||||
let area_before = area(&s, "Shell");
|
||||
// The fairing reports both energies, the second no larger than the first.
|
||||
let reply = run(&mut s, "surface.fair", json!({"body": "Shell", "tolerance": 1.0, "iterations": 3}));
|
||||
let (energy_before, energy_after) = energies(&reply);
|
||||
let area_after = area(&s, "Shell");
|
||||
eprintln!("surface.fair: {reply}\nenergies {energy_before:e} → {energy_after:e}, area {area_before} → {area_after}");
|
||||
assert!(energy_before > 0.0 && energy_after <= energy_before, "{energy_before:e} → {energy_after:e}");
|
||||
// The face moved (the fit is not the flattest surface on its own rim), but not its edges:
|
||||
// every boundary point of the body is where it was, bit for bit.
|
||||
assert!((area_after - area_before).abs() > 0.0 || energy_after < energy_before);
|
||||
let after: Vec<solidcraft_geom::Vec3> = st.body("Shell").unwrap().body.edges(0.1).unwrap().iter().flat_map(|e| e.points.clone()).collect();
|
||||
assert_eq!(after, before, "fairing must not move the face's edges");
|
||||
assert_eq!(st.body("Shell").unwrap().body.face_count(), 1);
|
||||
// Undo takes the fairing back, redo puts it back, and a cold reopen rebuilds the same.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!((area(&s, "Shell") - area_before).abs() < 1e-9, "undo did not take the fairing back");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!((area(&s, "Shell") - area_after).abs() < 1e-9, "redo did not put the fairing back");
|
||||
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((area(&cold, "Shell") - area_after).abs() < 1e-9, "a cold reopen did not rebuild the same sheet");
|
||||
// Refusals: an unknown body, a body whose faces are all planes, an out-of-range strength
|
||||
// or step count, and a missing tolerance expression.
|
||||
assert!(s.execute("surface.fair", &json!({"body": "Nope", "tolerance": 1.0})).is_err());
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Box"}));
|
||||
assert!(s.execute("surface.fair", &json!({"body": "Box", "tolerance": 1.0})).is_err());
|
||||
assert!(s.execute("surface.fair", &json!({"body": "Shell", "strength": 2, "tolerance": 1.0})).is_err());
|
||||
assert!(s.execute("surface.fair", &json!({"body": "Shell", "iterations": 0, "tolerance": 1.0})).is_err());
|
||||
assert!(s.execute("surface.fair", &json!({"body": "Shell", "tolerance": 0})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Boundary surfaces: one patch through a network of two to four sketch curves (`surface.boundary`,
|
||||
//! issue #763). The kernel builds the face from the curves' own frames; this is the command's
|
||||
//! parameters and its checks, so that a loop it cannot hold is refused before anything is built.
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{FeatureKind, SketchId};
|
||||
use solidcraft_kernel::Continuity;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, feature_sketch};
|
||||
use crate::params::{bad, string_list};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The stations a boundary carries by default (the patch's resolution along the loop).
|
||||
const DEFAULT_SAMPLES: usize = 25;
|
||||
/// The fewest stations a patch can hold the widest continuity pair with: a G3 side needs four rows
|
||||
/// at each of two opposite sides, so eight.
|
||||
const MIN_SAMPLES: usize = 8;
|
||||
/// The most stations a boundary may carry (the kernel's own cap).
|
||||
const MAX_SAMPLES: usize = 512;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("surface.boundary", "Boundary Surface", boundary)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("boundary")
|
||||
.params(
|
||||
"sketch?: id|name (default: the sketch being edited); curves: [2 to 4 sketch curve ids in loop order] (each one ends where the next begins and the last where the first does; the patch runs through them and leaves each boundary along the tangent across it); continuity?: g0|g1|g2|g3 | [one per curve] (default g1: how much of the surface across each boundary the patch repeats — a sketch curve's own plane, so g1 and up hold exactly and g0 lets the middle bow); crown?: number -0.9…3 (default 0: how full the middle is, without touching a boundary); samples?: 8…512 stations per boundary (default 25); name?, body_name? (a surface body)",
|
||||
)];
|
||||
|
||||
/// `surface.boundary`: one patch through a network of two to four sketch curves.
|
||||
fn boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.boundary";
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let curves = string_list(p, "curves");
|
||||
if !(2..=4).contains(&curves.len()) {
|
||||
return Err(bad(cmd, "`curves` must list 2 to 4 sketch curves in loop order"));
|
||||
}
|
||||
// The curves must be the sketch's own, and they must run on into one another: the last station
|
||||
// of each is the first of the next, and the last boundary's end is the first one's start.
|
||||
let state = s.model.state();
|
||||
let sk = state.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch has not been solved"))?;
|
||||
let mut ends: Vec<(solidcraft_geom::Vec2, solidcraft_geom::Vec2)> = Vec::with_capacity(curves.len());
|
||||
for id in &curves {
|
||||
let ci = sk.sketch.curve_index(id).ok_or_else(|| bad(cmd, format!("no sketch curve `{id}`")))?;
|
||||
let pieces = sk.sketch.exact_pieces(ci);
|
||||
let (Some(first), Some(last)) = (pieces.first(), pieces.last()) else {
|
||||
return Err(bad(cmd, format!("curve `{id}` has no exact geometry to build on")));
|
||||
};
|
||||
if last.end().dist(first.start()) < 1e-9 {
|
||||
return Err(bad(cmd, format!("curve `{id}` is closed: a boundary must be one side of the loop")));
|
||||
}
|
||||
ends.push((first.start(), last.end()));
|
||||
}
|
||||
// Two boundaries are the two sides of a bridge, which do not meet; three or four run round a
|
||||
// loop, so each has to end where the next begins.
|
||||
let scale = ends.iter().map(|(a, b)| a.dist(*b)).fold(1.0_f64, f64::max);
|
||||
let tol = scale * 1e-4 + 1e-9;
|
||||
for i in 0..if curves.len() >= 3 { ends.len() } else { 0 } {
|
||||
let next = (i + 1) % ends.len();
|
||||
let (Some(end), Some(start)) = (ends.get(i).map(|e| e.1), ends.get(next).map(|e| e.0)) else {
|
||||
return Err(bad(cmd, "a curve is missing its ends"));
|
||||
};
|
||||
let gap = end.dist(start);
|
||||
if gap > tol {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("curve `{}` ends {gap:.4} mm from where `{}` starts: list the curves in loop order", curves[i], curves[next]),
|
||||
));
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
let orders = match p.get("continuity") {
|
||||
None | Some(Value::Null) => vec![Continuity::G1; curves.len()],
|
||||
Some(Value::String(w)) => {
|
||||
let one = Continuity::parse(w).ok_or_else(|| bad(cmd, "`continuity` must be g0, g1, g2 or g3"))?;
|
||||
vec![one; curves.len()]
|
||||
}
|
||||
Some(v) => {
|
||||
let list = v.as_array().filter(|a| a.len() == curves.len()).ok_or_else(|| bad(cmd, "`continuity` must be one word, or one per curve"))?;
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
for x in list {
|
||||
out.push(x.as_str().and_then(Continuity::parse).ok_or_else(|| bad(cmd, "`continuity` entries must be g0, g1, g2 or g3"))?);
|
||||
}
|
||||
out
|
||||
}
|
||||
};
|
||||
let crown = match p.get("crown") {
|
||||
None | Some(Value::Null) => 0.0,
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|x| x.is_finite() && (solidcraft_geom::fill::MIN_CROWN..=solidcraft_geom::fill::MAX_CROWN).contains(x))
|
||||
.ok_or_else(|| bad(cmd, "`crown` must be a number between -0.9 and 3"))?,
|
||||
};
|
||||
let samples = match p.get("samples") {
|
||||
None | Some(Value::Null) => DEFAULT_SAMPLES,
|
||||
Some(v) => {
|
||||
let n = v.as_f64().filter(|x| x.is_finite() && x.fract() == 0.0).map(|x| x as usize);
|
||||
n.filter(|x| (MIN_SAMPLES..=MAX_SAMPLES).contains(x))
|
||||
.ok_or_else(|| bad(cmd, format!("`samples` must be a whole number of stations between {MIN_SAMPLES} and {MAX_SAMPLES}")))?
|
||||
}
|
||||
};
|
||||
add_feature(s, p, FeatureKind::SurfaceBoundary { sketch: SketchId(sketch), curves, orders, crown, samples })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<String> {
|
||||
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn new_sketch() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// Two parallel 20 mm lines 10 mm apart, and the patch between them.
|
||||
fn two_walls(s: &mut Session) -> Vec<String> {
|
||||
let a = ids(&run(s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["created"]["curves"])[0].clone();
|
||||
let b = ids(&run(s, "sketch.line", json!({"points": [[0, 10], [20, 10]]}))["created"]["curves"])[0].clone();
|
||||
vec![a, b]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boundary_patch_between_two_sketch_curves_is_the_sheet_they_span() {
|
||||
for continuity in [json!("g0"), json!("g1"), json!("g2"), json!(["g1", "g2"])] {
|
||||
let mut s = new_sketch();
|
||||
let curves = two_walls(&mut s);
|
||||
let r = run(&mut s, "surface.boundary", json!({"curves": curves, "continuity": continuity}));
|
||||
let body = ids(&r["created_bodies"])[0].clone();
|
||||
// It is one face, its boundary is the two lines themselves, and it covers exactly the
|
||||
// 20 × 10 sheet between them — all four frames lie in the sketch's plane.
|
||||
let f = run(&mut s, "model.faces", json!({"body": body}));
|
||||
let faces = f["faces"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(faces.len(), 1, "{continuity}: {f}");
|
||||
assert!((faces[0]["area"].as_f64().unwrap_or(f64::NAN) - 200.0).abs() < 1e-4, "{continuity}: {f}");
|
||||
let normal = faces[0]["plane_normal"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(normal.len(), 3, "{continuity}: the patch is planar: {f}");
|
||||
assert!(normal[2].as_f64().unwrap_or(0.0).abs() > 1.0 - 1e-9, "{continuity}: it lies in the sketch's plane: {f}");
|
||||
let m = run(&mut s, "inspect.measure", json!({}));
|
||||
let area = m["total"]["area_mm2"].as_f64().unwrap_or(f64::NAN);
|
||||
assert!((area - 200.0).abs() < 1e-4, "{continuity}: {area}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boundary_patch_refuses_a_loop_and_values_it_cannot_hold() {
|
||||
let mut s = new_sketch();
|
||||
let curves = two_walls(&mut s);
|
||||
let extra = ids(&run(&mut s, "sketch.line", json!({"points": [[30, 30], [40, 40]]}))["created"]["curves"])[0].clone();
|
||||
// A loop of three or four: the kernel refuses it with its own reason (a tensor-product patch
|
||||
// cannot hold two sides' data at a corner, issue #763) rather than building it approximately.
|
||||
let triangle = ["l1", "l2", "l3"].map(|id| {
|
||||
let _ = id;
|
||||
String::new()
|
||||
});
|
||||
let _ = triangle;
|
||||
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["created"]["curves"])[0].clone();
|
||||
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 0], [10, 15]]}))["created"]["curves"])[0].clone();
|
||||
let c = ids(&run(&mut s, "sketch.line", json!({"points": [[10, 15], [0, 0]]}))["created"]["curves"])[0].clone();
|
||||
let e = s.execute("surface.boundary", &json!({"curves": [a, b, c], "continuity": "g2"})).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet") && e.contains("#763"), "three boundaries: {e}");
|
||||
// A closed square of four lines, in loop order.
|
||||
let sq = [[[0, 0], [10, 0]], [[10, 0], [10, 10]], [[10, 10], [0, 10]], [[0, 10], [0, 0]]];
|
||||
let square: Vec<String> = sq.iter().map(|p| ids(&run(&mut s, "sketch.line", json!({"points": p}))["created"]["curves"])[0].clone()).collect();
|
||||
let e = s.execute("surface.boundary", &json!({"curves": square})).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet") && e.contains("#763"), "four boundaries: {e}");
|
||||
// Parameters and curves that make no sense.
|
||||
let cases: Vec<(Value, &str)> = vec![
|
||||
(json!({"curves": [curves[0]]}), "2 to 4"),
|
||||
(json!({"curves": [curves[0], curves[1], extra.clone(), extra.clone(), extra.clone()]}), "2 to 4"),
|
||||
(json!({"curves": [curves[0], "nope"]}), "no sketch curve"),
|
||||
(json!({"curves": curves, "continuity": "g9"}), "continuity"),
|
||||
(json!({"curves": curves, "continuity": ["g1", "g2", "g3"]}), "continuity"),
|
||||
(json!({"curves": curves, "crown": 5}), "crown"),
|
||||
(json!({"curves": curves, "crown": "full"}), "crown"),
|
||||
(json!({"curves": curves, "samples": 3}), "samples"),
|
||||
(json!({"curves": curves, "samples": 2.5}), "samples"),
|
||||
];
|
||||
for (params, want) in cases {
|
||||
let e = s.execute("surface.boundary", ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains(want), "{params}: {e}");
|
||||
}
|
||||
// Nothing was built by any of them.
|
||||
assert!(s.model.state().bodies.is_empty(), "a refused patch left a body behind");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! `surface.match`: rebuild a surface body's face so that one of its boundaries takes the place of
|
||||
//! a picked edge chain of another body, repeating that body's shape along the seam up to a
|
||||
//! requested continuity, while the boundary opposite it keeps its place. The body keeps its name:
|
||||
//! a match is a modify, like Trim, Extend and Fair.
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{BodyName, FacePick, FeatureKind, Position};
|
||||
use solidcraft_kernel::Continuity;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::add_feature;
|
||||
use crate::params::{bad, str_, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("surface.match", "Match Surface", match_surface)
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("continuity")
|
||||
.params(
|
||||
"body: surface name (the body whose face is rebuilt); faces?: [[x,y,z]] (points on that face, default: the body's first); target: body name (the body the seam must meet); edges: [[x,y,z]] (points on one edge chain of the target: the rebuilt boundary takes its place); order?: g1|g2|g3 (default g2: position and tangent plane, the curvature too, or how that curvature changes along the seam). The far boundary stays where it was and the body keeps its name",
|
||||
),
|
||||
];
|
||||
|
||||
/// `surface.match`: one face of `body` is rebuilt so that its boundary nearest the picked chain of
|
||||
/// `target` lies on that chain and repeats the target's own shape there up to `order`.
|
||||
fn match_surface(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.match";
|
||||
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
|
||||
if s.model.state().body(&body).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{body}`")));
|
||||
}
|
||||
let target = str_(p, "target").ok_or_else(|| bad(cmd, "`target` is required"))?.to_string();
|
||||
if s.model.state().body(&target).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{target}`")));
|
||||
}
|
||||
let faces: Vec<solidcraft_geom::Vec3> = p.get("faces").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
|
||||
if faces.len() > 64 {
|
||||
return Err(bad(cmd, "too many points on the face to rebuild"));
|
||||
}
|
||||
let edges: Vec<solidcraft_geom::Vec3> = p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
|
||||
if edges.is_empty() || edges.len() > 10_000 {
|
||||
return Err(bad(cmd, "`edges` must list 1 to 10000 points on the target's edge chain"));
|
||||
}
|
||||
let order = match p.get("order") {
|
||||
None => Continuity::G2,
|
||||
Some(v) => v.as_str().and_then(Continuity::parse).filter(|o| *o != Continuity::G0).ok_or_else(|| bad(cmd, "`order` must be g1, g2 or g3"))?,
|
||||
};
|
||||
add_feature(
|
||||
s,
|
||||
p,
|
||||
FeatureKind::SurfaceMatch {
|
||||
body: BodyName::from(body),
|
||||
faces: faces.into_iter().map(FacePick).collect(),
|
||||
target: BodyName::from(target),
|
||||
edges: edges.into_iter().map(Position).collect(),
|
||||
order,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
fn run(s: &mut Session, c: &str, p: Value) -> Value {
|
||||
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
|
||||
}
|
||||
|
||||
/// The picked chain: two points of the cylinder's own vertical wall seam, clear of the corners
|
||||
/// at either end. The seam is where `solid.extrude` starts the circle, so it is read off the
|
||||
/// body rather than assumed.
|
||||
fn picks(s: &Session) -> Vec<Vec3> {
|
||||
let boss = s.world_state().body("Boss").unwrap().body.clone();
|
||||
let e = boss
|
||||
.edges(boss.size() * 1e-2)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|e| e.length > 25.0 && (e.mid.x.hypot(e.mid.y) - 10.0).abs() < 0.05 && e.mid.z > 5.0 && e.mid.z < 25.0)
|
||||
.expect("the cylinder has a straight vertical wall seam");
|
||||
let (a, b) = (e.points.first().copied(), e.points.last().copied());
|
||||
let (a, b) = (a.unwrap_or(e.mid), b.unwrap_or(e.mid));
|
||||
vec![a + (b - a) * 0.25, a + (b - a) * 0.75]
|
||||
}
|
||||
|
||||
/// A cylinder of radius 10, 30 high, and a cylindrical skirt of radius 11 from the cylinder's
|
||||
/// own wall seam round 60 degrees: the skirt's near boundary is 1 mm off the seam line.
|
||||
fn boss_and_skirt(s: &mut Session) {
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Round"}));
|
||||
run(s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"sketch": "Round", "distance": 30, "body_name": "Boss"}));
|
||||
let seam = picks(s)[0];
|
||||
let a = seam.y.atan2(seam.x);
|
||||
let at = |t: f64| json!([11.0 * t.cos(), 11.0 * t.sin()]);
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Skirt sketch"}));
|
||||
let arc = run(s, "sketch.arc.three_point", json!({"start": at(a), "end": at(a + 60f64.to_radians()), "through": at(a + 30f64.to_radians())}))
|
||||
["created"]["curves"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "surface.extrude", json!({"sketch": "Skirt sketch", "curves": [arc], "distance": 30, "body_name": "Skirt"}));
|
||||
}
|
||||
|
||||
/// The seam of a matched skirt, measured back off the two bodies: how far the rebuilt boundary
|
||||
/// is from the picked seam line, the angle between the two surfaces' normals across it, and
|
||||
/// the continuity it holds.
|
||||
fn seam(s: &Session) -> (f64, f64, Continuity) {
|
||||
let st = s.world_state();
|
||||
let made = st.body("Skirt").unwrap().body.clone();
|
||||
let target = st.body("Boss").unwrap().body.clone();
|
||||
solidcraft_kernel::seam_continuity(&made, &target, &picks(s)).unwrap()
|
||||
}
|
||||
|
||||
/// `surface.match`: a skirt meets a cylinder's wall seam at g2; the body keeps its name, undo
|
||||
/// and redo move it back and forth, a cold reopen gives the same seam, and what cannot be
|
||||
/// matched is refused.
|
||||
#[test]
|
||||
fn surface_match_rebuilds_a_skirt_onto_a_cylinder() {
|
||||
let mut s = Session::default();
|
||||
boss_and_skirt(&mut s);
|
||||
// Before the match the skirt's boundary is the 1 mm-wider line it was extruded at.
|
||||
let (gap, _, _) = seam(&s);
|
||||
assert!((gap - 1.0).abs() < 0.05, "the skirt starts a millimetre off the seam: {gap}");
|
||||
let chain = picks(&s);
|
||||
run(&mut s, "surface.match", json!({"body": "Skirt", "target": "Boss", "edges": chain, "order": "g2"}));
|
||||
let st = s.world_state();
|
||||
assert!(st.body("Skirt").is_some(), "the matched body keeps its name");
|
||||
assert!(st.body("Boss").is_some(), "the body it was matched to stays too");
|
||||
assert!(st.body("Skirt").unwrap().body.is_surface(), "a match stays a surface");
|
||||
let (gap, deg, order) = seam(&s);
|
||||
println!("engine: gap {gap:e} mm, normal {deg:e} deg, order {order:?}");
|
||||
assert!(gap < 1e-6, "the seam is on the picked line: {gap:e} mm");
|
||||
assert!(deg < 0.05, "the seam holds the wall's tangent plane: {deg} deg");
|
||||
assert!(order.conditions() >= Continuity::G1.conditions(), "{order:?}");
|
||||
// Undo puts the boundary back, redo matches it again, and a cold reopen agrees.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(seam(&s).0 > 0.5, "undo restores the original skirt");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(seam(&s).1 < 0.05, "redo matches it again");
|
||||
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(seam(&cold).1 < 0.05, "a reopened design matches the same");
|
||||
// Refusals: an unknown body, no target, no picks on the target, a word that is no order,
|
||||
// and a body that is not a surface.
|
||||
assert!(s.execute("surface.match", &json!({"body": "Nope", "target": "Boss", "edges": picks(&s)})).is_err());
|
||||
assert!(s.execute("surface.match", &json!({"body": "Skirt", "edges": picks(&s)})).is_err());
|
||||
assert!(s.execute("surface.match", &json!({"body": "Skirt", "target": "Boss", "edges": []})).is_err());
|
||||
assert!(s.execute("surface.match", &json!({"body": "Skirt", "target": "Nope", "edges": picks(&s)})).is_err());
|
||||
assert!(s.execute("surface.match", &json!({"body": "Skirt", "target": "Boss", "edges": picks(&s), "order": "g5"})).is_err());
|
||||
assert!(s.execute("surface.match", &json!({"body": "Skirt", "target": "Boss", "edges": [[100, 0, 0]]})).is_err());
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "B"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [4, 4]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "B", "distance": 4, "body_name": "Block"}));
|
||||
assert!(s.execute("surface.match", &json!({"body": "Block", "target": "Boss", "edges": picks(&s)})).is_err(), "a solid is not a surface");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Boundary patches: one face through the boundary curves of a sketch, each side meeting the
|
||||
//! surface it was taken from at a requested continuity (G0 up to G3).
|
||||
//!
|
||||
//! Two boundaries are the two sides of a [`crate::bridge`]: the patch is the Hermite surface that
|
||||
//! leaves each boundary along the direction its frames state and repeats as much of that support
|
||||
//! surface — its tangent plane, its curvature, the change of that curvature — as the side's order
|
||||
//! asks for, with `crown` shaping the middle without touching a boundary condition.
|
||||
//!
|
||||
//! A patch through *three or four* boundaries is refused rather than built approximately. At a
|
||||
//! corner two sides prescribe the same entries of the control net from two different surfaces, and
|
||||
//! a tensor-product patch can hold both only where those two prescriptions already agree (one wall
|
||||
//! carrying every side, say). Where they do not — the general case — the requested order would
|
||||
//! silently drop near the corner, or the boundary curve would visibly leave its stations, and a
|
||||
//! Gregory-type corner (a rational blend of the sides' own data) is needed to hold them instead
|
||||
//! (issue #763). Everything this module refuses is an error, never a quietly worse surface.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::bridge::{Continuity, Frame, bridge};
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
/// The most stations a boundary of a patch may carry.
|
||||
const MAX_SAMPLES: usize = 512;
|
||||
|
||||
/// One boundary of a patch: the stations along it, and how much of the surface it was taken from
|
||||
/// the patch repeats there.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Boundary {
|
||||
/// The stations along the boundary, in the order they run: the end of one boundary meets the
|
||||
/// start of the next, and the last meets the first.
|
||||
pub frames: Vec<Frame>,
|
||||
/// How much of the neighbouring surface the patch repeats at this boundary.
|
||||
pub order: Continuity,
|
||||
}
|
||||
|
||||
/// A two-sided patch: one face that meets boundary `i` at the continuity its `order` asks for (G0 up
|
||||
/// to G3), with `samples` stations every boundary must carry and `crown` shaping the middle of the
|
||||
/// patch without touching any boundary condition (0 the plain blend, up to 3 fuller, down to -0.9
|
||||
/// flatter). Three or four boundaries are refused: see the module docs.
|
||||
pub fn boundary_patch(boundaries: &[Boundary], crown: f64, samples: usize) -> Result<Body> {
|
||||
check_input(boundaries, crown, samples)?;
|
||||
if boundaries.len() > 2 {
|
||||
return Err(KernelError::Invalid(format!(
|
||||
"not supported yet: a patch through {} boundaries — a three- or four-sided patch needs a Gregory-type corner (issue #763); \
|
||||
give two boundaries, which is the bridge this builds exactly",
|
||||
boundaries.len()
|
||||
)));
|
||||
}
|
||||
let (Some(a), Some(b)) = (boundaries.first(), boundaries.get(1)) else {
|
||||
return Err(KernelError::Invalid("a boundary patch joins 2 boundaries: one of them is missing".into()));
|
||||
};
|
||||
bridge(&a.frames, &b.frames, [a.order, b.order], [crown, crown])
|
||||
}
|
||||
|
||||
/// Everything a patch refuses, before any geometry is built.
|
||||
fn check_input(boundaries: &[Boundary], crown: f64, samples: usize) -> Result<()> {
|
||||
let n = boundaries.len();
|
||||
if !(2..=4).contains(&n) {
|
||||
return Err(KernelError::Invalid(format!("a boundary patch joins 2 to 4 boundaries, {n} given")));
|
||||
}
|
||||
if !(2..=MAX_SAMPLES).contains(&samples) {
|
||||
return Err(KernelError::Invalid(format!("a boundary patch needs 2 to {MAX_SAMPLES} stations per boundary, {samples} given")));
|
||||
}
|
||||
if !crown.is_finite() || !(-0.9..=3.0).contains(&crown) {
|
||||
return Err(KernelError::Invalid("a boundary patch's crown must be between -0.9 and 3".into()));
|
||||
}
|
||||
for (i, b) in boundaries.iter().enumerate() {
|
||||
if b.frames.len() != samples {
|
||||
return Err(KernelError::Invalid(format!(
|
||||
"boundary {i} carries {} stations, not the {samples} every boundary of a patch needs",
|
||||
b.frames.len()
|
||||
)));
|
||||
}
|
||||
for f in &b.frames {
|
||||
if ![f.p, f.leave, f.bend, f.bend_change].into_iter().all(Vec3::is_finite) {
|
||||
return Err(KernelError::Invalid(format!("boundary {i} has a station that is not finite")));
|
||||
}
|
||||
if f.leave.normalized().is_none() {
|
||||
return Err(KernelError::Invalid(format!("boundary {i} has a station with no leave direction")));
|
||||
}
|
||||
}
|
||||
if boundary_length(&b.frames) <= 1e-9 {
|
||||
return Err(KernelError::Invalid(format!("boundary {i} has no length: a patch needs a curve, not a single point")));
|
||||
}
|
||||
}
|
||||
// Two boundaries are the two sides of a bridge, which do not meet; three or four would run round
|
||||
// a loop, so each one would have to end where the next one begins.
|
||||
if n >= 3 {
|
||||
let mut scale: f64 = 1.0;
|
||||
for b in boundaries {
|
||||
scale = scale.max(boundary_length(&b.frames));
|
||||
}
|
||||
let tol = scale * 1e-4 + 1e-9;
|
||||
for i in 0..n {
|
||||
let (Some(a), Some(b)) = (boundaries.get(i), boundaries.get((i + 1) % n)) else { continue };
|
||||
let (Some(end), Some(start)) = (a.frames.last(), b.frames.first()) else { continue };
|
||||
let gap = end.p.dist(start.p);
|
||||
if gap > tol {
|
||||
return Err(KernelError::Invalid(format!(
|
||||
"boundary {i} ends and boundary {} starts {gap:.6} mm apart: the boundaries of a loop must run on into one another",
|
||||
(i + 1) % n
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The length of a run of stations.
|
||||
fn boundary_length(frames: &[Frame]) -> f64 {
|
||||
let mut length: f64 = 0.0;
|
||||
for w in frames.windows(2) {
|
||||
if let (Some(a), Some(b)) = (w.first(), w.get(1)) {
|
||||
length += a.p.dist(b.p);
|
||||
}
|
||||
}
|
||||
length
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "boundary_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Tests of boundary patches: the bridge this builds is measured back off the face it made — not
|
||||
//! from the construction — and everything it cannot hold is an error rather than a quiet
|
||||
//! approximation.
|
||||
|
||||
use super::*;
|
||||
use crate::body::from_p3;
|
||||
use truck_modeling::ParametricSurface;
|
||||
|
||||
/// The angle between two vectors, in degrees.
|
||||
fn angle_deg(a: Vec3, b: Vec3) -> f64 {
|
||||
(a.normalized().unwrap().dot(b.normalized().unwrap())).clamp(-1.0, 1.0).acos().to_degrees()
|
||||
}
|
||||
|
||||
/// The geometric shape of a surface curve from its first three derivatives along it: the unit
|
||||
/// tangent, the curvature vector and the arclength derivative of that curvature vector — the same
|
||||
/// reading `bridge` states its conditions with.
|
||||
fn shape_of(d1: Vec3, d2: Vec3, d3: Vec3) -> (Vec3, Vec3, Vec3) {
|
||||
let sigma = d1.len();
|
||||
let t = d1 * (1.0 / sigma);
|
||||
let tp = d2 * (1.0 / sigma) - d1 * (d1.dot(d2) / (sigma * sigma * sigma));
|
||||
let nn = d2 - t * d2.dot(t);
|
||||
let np = d3 - t * d3.dot(t) - tp * d2.dot(t) - t * d2.dot(tp);
|
||||
let kappa = np * (sigma * sigma) - nn * (2.0 * d1.dot(d2));
|
||||
(t, nn * (1.0 / (sigma * sigma)), kappa * (1.0 / (sigma.powi(5))))
|
||||
}
|
||||
|
||||
/// Across a boundary: five samples a step `h` apart, `into` the patch from the parameter `(u, v)`,
|
||||
/// read back as the position, the unit tangent, the curvature vector and the change of curvature
|
||||
/// along that inward direction.
|
||||
fn across(body: &Body, uv: (f64, f64), into: (f64, f64), h: f64) -> (Vec3, Vec3, Vec3, Vec3) {
|
||||
let face = body.solid.face_iter().next().unwrap();
|
||||
let surf = face.surface();
|
||||
let q: Vec<Vec3> = (0..5).map(|k| from_p3(surf.subs(uv.0 + k as f64 * h * into.0, uv.1 + k as f64 * h * into.1))).collect();
|
||||
let (f0, f1, f2, f3, f4) = (q[0], q[1], q[2], q[3], q[4]);
|
||||
let d1 = (f0 * -25.0 + f1 * 48.0 + f2 * -36.0 + f3 * 16.0 + f4 * -3.0) * (1.0 / (12.0 * h));
|
||||
let d2 = (f0 * 35.0 + f1 * -104.0 + f2 * 114.0 + f3 * -56.0 + f4 * 11.0) * (1.0 / (12.0 * h * h));
|
||||
let d3 = (f0 * -5.0 + f1 * 18.0 + f2 * -24.0 + f3 * 14.0 + f4 * -3.0) * (1.0 / (2.0 * h * h * h));
|
||||
let (t, bend, change) = shape_of(d1, d2, d3);
|
||||
(f0, t, bend, change)
|
||||
}
|
||||
|
||||
/// The stations' parameters: `n` of them, evenly spread over the patch.
|
||||
fn stations(n: usize) -> Vec<f64> {
|
||||
(0..n).map(|i| i as f64 / (n - 1) as f64).collect()
|
||||
}
|
||||
|
||||
/// The normal angle a side may be off by.
|
||||
const ANGLE_TOL: f64 = 0.01;
|
||||
/// How much of the supporting wall's own curvature a side must repeat, relative to it.
|
||||
const CURV_TOL: f64 = 1e-3;
|
||||
/// The normal part of the change of curvature a G3 side may leave over, relative to 1/r² (the
|
||||
/// finite differences that read the third derivative off the face amplify round-off by 1/h³, so
|
||||
/// this is a noise floor rather than a geometric tolerance).
|
||||
const CHANGE_TOL: f64 = 1e-4;
|
||||
|
||||
/// Measure one side of a built patch at every station of `frames`, and check what its order asks
|
||||
/// for: the boundary curve itself always, the tangent plane from G1 on, the curvature vector from
|
||||
/// G2 on and its change along the leave direction at G3.
|
||||
fn check_side(body: &Body, frames: &[Frame], order: Continuity, at: impl Fn(usize) -> ((f64, f64), (f64, f64)), r: f64) {
|
||||
for (i, f) in frames.iter().enumerate() {
|
||||
let (uv, into) = at(i);
|
||||
let (p, t, bend, change) = across(body, uv, into, 1e-3);
|
||||
let gap = p.dist(f.p);
|
||||
assert!(gap < 1e-9, "station {i} sits on its boundary: {p:?} vs {:?} ({gap:.2e} mm)", f.p);
|
||||
if order == Continuity::G0 {
|
||||
continue;
|
||||
}
|
||||
assert!(angle_deg(t, f.leave) < ANGLE_TOL, "station {i} leaves along the tangent: {t:?} vs {:?}", f.leave);
|
||||
if order == Continuity::G1 {
|
||||
continue;
|
||||
}
|
||||
if f.bend.len() > 1e-9 {
|
||||
assert!(bend.dist(f.bend) / f.bend.len() < CURV_TOL, "station {i}: curvature {bend:?} vs the wall's {:?}", f.bend);
|
||||
if order == Continuity::G3 {
|
||||
let normal = f.bend * (1.0 / f.bend.len());
|
||||
assert!(change.dot(normal).abs() < CHANGE_TOL / (r * r), "station {i}: the change of curvature has a normal part {change:?}");
|
||||
}
|
||||
} else {
|
||||
assert!(bend.len() < CURV_TOL / r, "station {i}: the wall is straight here, the patch curves by {bend:?}");
|
||||
if order == Continuity::G3 {
|
||||
assert!(change.len() < CHANGE_TOL / (r * r), "station {i}: a straight cross-section changes curvature by {change:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two walls 20 mm apart, each leaving towards the other, both walls of the same cylinder of radius
|
||||
/// `r` (the valley `bridge` is tested with): the two sides of a two-sided patch.
|
||||
fn two_walls(n: usize, r: f64) -> (Vec<Frame>, Vec<Frame>) {
|
||||
let mut a = Vec::new();
|
||||
let mut b = Vec::new();
|
||||
for t in stations(n) {
|
||||
let y = 20.0 * t;
|
||||
a.push(Frame {
|
||||
p: Vec3::new(0.0, y, 0.0),
|
||||
leave: Vec3::new(0.0, 1.0, 0.0),
|
||||
bend: Vec3::new(0.0, 0.0, 1.0 / r),
|
||||
bend_change: Vec3::new(0.0, -1.0 / (r * r), 0.0),
|
||||
});
|
||||
b.push(Frame {
|
||||
p: Vec3::new(0.0, y + 20.0, 0.0),
|
||||
leave: Vec3::new(0.0, -1.0, 0.0),
|
||||
bend: Vec3::new(0.0, 0.0, 1.0 / r),
|
||||
bend_change: Vec3::new(0.0, 1.0 / (r * r), 0.0),
|
||||
});
|
||||
}
|
||||
(a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_two_sided_patch_is_a_bridge_that_holds_both_ends() {
|
||||
let n = 9;
|
||||
let r = 10.0;
|
||||
let (a, b) = two_walls(n, r);
|
||||
let boundaries = vec![Boundary { frames: a.clone(), order: Continuity::G2 }, Boundary { frames: b.clone(), order: Continuity::G2 }];
|
||||
let body = boundary_patch(&boundaries, 0.0, n).unwrap();
|
||||
let s = stations(n);
|
||||
check_side(&body, &a, Continuity::G2, |i| ((s[i], 0.0), (0.0, 1.0)), r);
|
||||
check_side(&body, &b, Continuity::G2, |i| ((s[i], 1.0), (0.0, -1.0)), r);
|
||||
}
|
||||
|
||||
/// Four chained sides round a 20 × 20 square in the XY plane, each leaving into the square: the
|
||||
/// loop a patch through three or four boundaries would have to close.
|
||||
fn square_loop(n: usize) -> Vec<Boundary> {
|
||||
let corners = [(0.0_f64, 0.0_f64), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0)];
|
||||
let leaves = [Vec3::new(0.0, 1.0, 0.0), Vec3::new(-1.0, 0.0, 0.0), Vec3::new(0.0, -1.0, 0.0), Vec3::new(1.0, 0.0, 0.0)];
|
||||
(0..4)
|
||||
.map(|side| {
|
||||
let (a, b) = (corners[side], corners[(side + 1) % 4]);
|
||||
let frames = stations(n)
|
||||
.into_iter()
|
||||
.map(|t| Frame {
|
||||
p: Vec3::new(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t, 0.0),
|
||||
leave: leaves[side],
|
||||
bend: Vec3::ZERO,
|
||||
bend_change: Vec3::ZERO,
|
||||
})
|
||||
.collect();
|
||||
Boundary { frames, order: Continuity::G2 }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_two_sided_patch_meets_its_walls_up_to_the_curvature() {
|
||||
let n = 11;
|
||||
let r = 10.0;
|
||||
let (a, b) = two_walls(n, r);
|
||||
// G0, G1 and G2 are what this patch states: position, tangent plane and curvature across each
|
||||
// boundary. (A G3 bridge's third-order rows are `bridge`'s own subject, measured in its tests.)
|
||||
for order in [Continuity::G0, Continuity::G1, Continuity::G2] {
|
||||
let boundaries = vec![Boundary { frames: a.clone(), order }, Boundary { frames: b.clone(), order }];
|
||||
let body = boundary_patch(&boundaries, 0.0, n).unwrap();
|
||||
let s = stations(n);
|
||||
if order == Continuity::G0 {
|
||||
for (i, f) in a.iter().enumerate() {
|
||||
let (p, _, _, _) = across(&body, (s[i], 0.0), (0.0, 1.0), 1e-3);
|
||||
assert!(p.dist(f.p) < 1e-9, "station {i}: {p:?} vs {:?}", f.p);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
check_side(&body, &a, order, |i| ((s[i], 0.0), (0.0, 1.0)), r);
|
||||
check_side(&body, &b, order, |i| ((s[i], 1.0), (0.0, -1.0)), r);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crown_shapes_the_middle_without_touching_the_boundary_rows() {
|
||||
let n = 9;
|
||||
let r = 10.0;
|
||||
let (a, b) = two_walls(n, r);
|
||||
let boundaries = vec![Boundary { frames: a.clone(), order: Continuity::G2 }, Boundary { frames: b.clone(), order: Continuity::G2 }];
|
||||
let s = stations(n);
|
||||
let mut middles = Vec::new();
|
||||
for crown in [-0.5, 0.0, 1.0, 2.0] {
|
||||
let body = boundary_patch(&boundaries, crown, n).unwrap();
|
||||
let grid = crate::bridge::sample_grid(&body, 9, 9).unwrap();
|
||||
middles.push(grid[4][4].z);
|
||||
// The interior is the cross-section between the two walls: its middle sits in the gap, not
|
||||
// outside it, and it stays finite everywhere on the grid.
|
||||
for row in &grid {
|
||||
for p in row {
|
||||
assert!(p.is_finite() && (-2.0..=42.0).contains(&p.y), "the interior stays between the walls: {p:?}");
|
||||
}
|
||||
}
|
||||
assert!((18.0..=22.0).contains(&grid[4][4].y), "the middle of the patch sits midway in the 20 mm band: {:?}", grid[4][4]);
|
||||
// The conditions are untouched: the same boundary points, tangent and curvature.
|
||||
check_side(&body, &a, Continuity::G2, |i| ((s[i], 0.0), (0.0, 1.0)), r);
|
||||
check_side(&body, &b, Continuity::G2, |i| ((s[i], 1.0), (0.0, -1.0)), r);
|
||||
}
|
||||
let steps: Vec<f64> = middles.windows(2).map(|w| w[1] - w[0]).collect();
|
||||
assert!(steps.iter().all(|d| d.abs() > 1e-9), "a fuller crown moves the middle: {middles:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_patch_refuses_what_it_cannot_hold() {
|
||||
let n = 9;
|
||||
let (a, b) = two_walls(n, 10.0);
|
||||
let two = vec![Boundary { frames: a.clone(), order: Continuity::G2 }, Boundary { frames: b.clone(), order: Continuity::G2 }];
|
||||
// Not 2 to 4 boundaries.
|
||||
assert!(boundary_patch(&[], 0.0, n).is_err());
|
||||
assert!(boundary_patch(&two[..1], 0.0, n).is_err());
|
||||
let five = vec![two[0].clone(), two[1].clone(), two[0].clone(), two[1].clone(), two[0].clone()];
|
||||
assert!(boundary_patch(&five, 0.0, n).is_err());
|
||||
// Three or four sides are refused: a tensor-product patch cannot hold two sides' data at a
|
||||
// corner, which needs a Gregory-type corner (issue #763). The message says so.
|
||||
let square = square_loop(n);
|
||||
let e = boundary_patch(&square, 0.0, n).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet") && e.contains("#763"), "4 sides: {e}");
|
||||
// Three sides that do close: a triangle, whose last side runs from the second one's end back to
|
||||
// the first one's start.
|
||||
let triangle: Vec<Boundary> = vec![
|
||||
square[0].clone(),
|
||||
square[1].clone(),
|
||||
Boundary {
|
||||
frames: stations(n)
|
||||
.into_iter()
|
||||
.map(|t| Frame {
|
||||
p: Vec3::new(20.0 * (1.0 - t), 20.0 * (1.0 - t), 0.0),
|
||||
leave: Vec3::new(-std::f64::consts::FRAC_1_SQRT_2, -std::f64::consts::FRAC_1_SQRT_2, 0.0),
|
||||
bend: Vec3::ZERO,
|
||||
bend_change: Vec3::ZERO,
|
||||
})
|
||||
.collect(),
|
||||
order: Continuity::G2,
|
||||
},
|
||||
];
|
||||
let e = boundary_patch(&triangle, 0.0, n).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet") && e.contains("#763"), "3 sides: {e}");
|
||||
// A loop whose sides do not even meet is refused earlier and for its own reason.
|
||||
let broken = square_loop(n);
|
||||
let mut moved = broken[1].frames.clone();
|
||||
moved[0] = Frame { p: Vec3::new(30.0, 0.0, 0.0), ..moved[0] };
|
||||
let broken = vec![broken[0].clone(), Boundary { frames: moved, order: Continuity::G2 }, broken[2].clone()];
|
||||
let e = boundary_patch(&broken, 0.0, n).unwrap_err().to_string();
|
||||
assert!(e.contains("run on into one another"), "{e}");
|
||||
// The stations and the crown.
|
||||
let short = vec![Boundary { frames: a[..n - 1].to_vec(), order: Continuity::G2 }, two[1].clone()];
|
||||
assert!(boundary_patch(&short, 0.0, n).is_err(), "a boundary with the wrong number of stations");
|
||||
assert!(boundary_patch(&two, 0.0, n - 1).is_err());
|
||||
assert!(boundary_patch(&two, 0.0, 1).is_err());
|
||||
for crown in [-1.5, 3.5, f64::NAN, f64::INFINITY] {
|
||||
assert!(boundary_patch(&two, crown, n).is_err(), "crown {crown}");
|
||||
}
|
||||
// A station that is not finite, and a station with no leave direction.
|
||||
for field in 0..4 {
|
||||
let mut bad = two.clone();
|
||||
let mut frames = bad[0].frames.clone();
|
||||
frames[3] = match field {
|
||||
0 => Frame { p: Vec3::new(f64::NAN, 0.0, 0.0), ..frames[3] },
|
||||
1 => Frame { leave: Vec3::new(f64::NAN, 0.0, 0.0), ..frames[3] },
|
||||
2 => Frame { bend: Vec3::new(f64::INFINITY, 0.0, 0.0), ..frames[3] },
|
||||
_ => Frame { bend_change: Vec3::new(f64::NAN, 0.0, 0.0), ..frames[3] },
|
||||
};
|
||||
if let Some(b) = bad.first_mut() {
|
||||
b.frames = frames;
|
||||
}
|
||||
assert!(boundary_patch(&bad, 0.0, n).is_err(), "a station that is not finite: field {field}");
|
||||
}
|
||||
let mut flat = two.clone();
|
||||
if let Some(b) = flat.first_mut() {
|
||||
b.frames = vec![Frame { p: Vec3::new(0.0, 1.0, 0.0), leave: Vec3::ZERO, bend: Vec3::ZERO, bend_change: Vec3::ZERO }; n];
|
||||
}
|
||||
assert!(boundary_patch(&flat, 0.0, n).is_err(), "a station with no leave direction");
|
||||
let point = vec![
|
||||
Boundary {
|
||||
frames: vec![Frame { p: Vec3::new(1.0, 1.0, 1.0), leave: Vec3::X, bend: Vec3::ZERO, bend_change: Vec3::ZERO }; n],
|
||||
order: Continuity::G1,
|
||||
},
|
||||
Boundary {
|
||||
frames: vec![Frame { p: Vec3::new(1.0, 1.0, 1.0), leave: Vec3::X, bend: Vec3::ZERO, bend_change: Vec3::ZERO }; n],
|
||||
order: Continuity::G1,
|
||||
},
|
||||
];
|
||||
assert!(boundary_patch(&point, 0.0, n).is_err(), "a boundary with no length");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -24,6 +24,7 @@ mod boolean_diagnostics;
|
||||
mod boolean_guard;
|
||||
mod boolean_record;
|
||||
mod bore_guard;
|
||||
mod boundary;
|
||||
mod bridge;
|
||||
mod build;
|
||||
mod clearance;
|
||||
@@ -36,6 +37,7 @@ mod emboss;
|
||||
mod face_params;
|
||||
#[cfg(test)]
|
||||
mod face_source_tests;
|
||||
mod fair;
|
||||
mod fill;
|
||||
mod freeform;
|
||||
pub mod fuzz;
|
||||
@@ -66,6 +68,7 @@ mod issue_871_tests;
|
||||
mod join_between_caps_tests;
|
||||
mod lip;
|
||||
mod loopblend;
|
||||
mod matchsurf;
|
||||
mod measure;
|
||||
mod mesh_convert;
|
||||
mod meshbody;
|
||||
@@ -125,6 +128,7 @@ pub use blend_corner::fillet_radii;
|
||||
pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper};
|
||||
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
||||
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
|
||||
pub use boundary::{Boundary, boundary_patch};
|
||||
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid};
|
||||
pub use build::{
|
||||
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep,
|
||||
@@ -132,11 +136,16 @@ pub use build::{
|
||||
};
|
||||
pub use delete_face::delete_faces;
|
||||
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
|
||||
pub use fair::{
|
||||
FairCurve, FairNet, FairOptions, FairReport, MAX_FAIR_ITERATIONS, MAX_FAIR_POINTS, fair_bspline_curve, fair_bspline_surface, fair_curve,
|
||||
fair_faces, fair_net, fair_nurbs_curve, fair_nurbs_surface, fair_surface,
|
||||
};
|
||||
pub use fill::{PATCH_ANGLE_TOL_DEG, PatchReport, PatchSpec, patch_edges_crowned, patch_edges_report, patch_edges_with};
|
||||
pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh};
|
||||
pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
|
||||
pub use iges_out::iges_export_bodies;
|
||||
pub use lip::raise_band;
|
||||
pub use matchsurf::{match_surface, seam_continuity};
|
||||
pub use measure::{BodyMeasure, measure};
|
||||
pub use mesh_convert::convert_prismatic_mesh;
|
||||
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
//! Surface matching: rebuild a surface body's face so that one boundary of it meets a picked
|
||||
//! edge chain of another body up to a requested continuity (G1, G2 or G3), while the boundary
|
||||
//! opposite it does not move.
|
||||
//!
|
||||
//! A match is the bridge core ([`crate::bridge`]) pointed at a different pair of boundaries. One
|
||||
//! end reads its position, tangent, curvature and change of curvature from the *target's* face
|
||||
//! along the picked chain — that is the end that moves. The other end reads them from the body's
|
||||
//! own surface along the boundary opposite it, built through the original boundary's own points:
|
||||
//! a match may shape the middle, never that seam. In between the cross-sections are the bridge's
|
||||
//! cubic Hermites, whose crown is free, so the crown whose surface stays closest to the original
|
||||
//! one — sampled on a grid and measured against it — is the one kept: a match moves the seam and
|
||||
//! as little else as the conditions allow.
|
||||
//!
|
||||
//! Which boundary of the face moves is read off the geometry: the one nearest the picked chain.
|
||||
//! The order a match may ask for is read off the target the same way: a chain that several faces
|
||||
//! of the target meet, with shapes that part company, is a seam the target itself does not hold
|
||||
//! beyond that order, and no surface can be told to carry a condition the target has not got.
|
||||
|
||||
use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
|
||||
use solidcraft_geom::Vec3;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, from_p3, p3};
|
||||
use crate::bridge::{self, Continuity, Frame};
|
||||
use crate::record::Rebuild;
|
||||
use crate::surfaces::surface_body;
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
/// Stations a match reads along the seam and along the boundary it keeps.
|
||||
const STATIONS: usize = 33;
|
||||
|
||||
/// Samples each way of the grid the rebuilt surface is compared with the original on.
|
||||
const GRID: usize = 7;
|
||||
|
||||
/// The crowns the match tries at each end: [`crate::bridge::bridge`]'s free knob, which shapes
|
||||
/// the middle without touching either end's conditions.
|
||||
const CROWNS: [f64; 5] = [-0.5, 0.0, 0.5, 1.0, 2.0];
|
||||
|
||||
/// Points a caller may pick one face with.
|
||||
const MAX_PICKS: usize = 64;
|
||||
|
||||
/// Samples per edge of a chain, for lengths and distances.
|
||||
const PER_EDGE: usize = 32;
|
||||
|
||||
/// A seam is credited with G1 when its normals agree this closely (degrees), with G2 when the
|
||||
/// two curvature vectors agree within this fraction of the larger of them, and with G3 when the
|
||||
/// change of curvature agrees within this fraction of the curvature over the body's size.
|
||||
const G1_DEG: f64 = 0.05;
|
||||
const G2_REL: f64 = 5e-3;
|
||||
const G3_REL: f64 = 5e-2;
|
||||
|
||||
/// Two faces of the target count as meeting each other at the picked chain when their normals
|
||||
/// agree this closely, their curvature within this fraction of the larger, and their change of
|
||||
/// curvature within this fraction of the curvature over the body's size.
|
||||
const SUPPLY_DEG: f64 = 0.05;
|
||||
const SUPPLY_REL: f64 = 2e-2;
|
||||
const SUPPLY_BEND_REL: f64 = 1e-1;
|
||||
|
||||
/// Rebuild the face of `body` picked by `faces` (points on it) so that its boundary nearest the
|
||||
/// chain of `target`'s edges picked by `target_pick` takes that chain's place and repeats the
|
||||
/// target's own shape along it up to `order` — position, tangent, curvature vector, and the
|
||||
/// change of that curvature vector along the direction the match leaves in. The boundary
|
||||
/// opposite keeps its place and the original surface's own frame, and the middle is shaped to
|
||||
/// stay as close to the original surface as the conditions allow.
|
||||
pub fn match_surface(body: &Body, faces: &[Vec3], target: &Body, target_pick: &[Vec3], order: Continuity) -> Result<Body> {
|
||||
body.require_brep("match")?;
|
||||
target.require_brep("match")?;
|
||||
if !body.is_surface() {
|
||||
return Err(KernelError::Invalid("match rebuilds a surface body: this one is closed".into()));
|
||||
}
|
||||
let face = face_near(body, faces)?;
|
||||
let chain = bridge::chain(target, target_pick)?;
|
||||
let near = nearest_side(&face, &chain)?;
|
||||
let far = near.opposite();
|
||||
let far_edges = side_edges(&face, far, body.size() * 1e-6 + 1e-9)?;
|
||||
let (mid_near, mid_far) = (chain_mid(&chain)?, Side::at(&face.surface(), &ranges(&face)?, far, 0.5));
|
||||
let leave = (mid_far - mid_near)
|
||||
.normalized()
|
||||
.filter(|l| l.is_finite())
|
||||
.ok_or_else(|| KernelError::Invalid("the far boundary sits on the picked chain: there is nothing to match".into()))?;
|
||||
// Which face of the target the match continues from, and how much of the requested order the
|
||||
// target itself holds along that chain.
|
||||
let candidates = support_faces(target, &chain, leave)?;
|
||||
let supply = supply_order(&candidates, &chain, leave, target.size())?;
|
||||
if order.conditions() > supply.conditions() {
|
||||
return Err(KernelError::Invalid(format!(
|
||||
"the target's own surfaces part company at the picked edges: they hold only {} there, so a seam cannot hold {}",
|
||||
supply.as_str(),
|
||||
order.as_str()
|
||||
)));
|
||||
}
|
||||
let Some((target_face, _)) = candidates.first() else {
|
||||
return Err(KernelError::Invalid("no face of the target holds the picked edges".into()));
|
||||
};
|
||||
// The stations of the seam: what the target does there along the direction the match leaves
|
||||
// in, and what the far boundary does coming back the other way.
|
||||
let mut near_frames = bridge::frames(target_face, &chain, leave, STATIONS)?;
|
||||
let far_frames = bridge::frames(&face, &far_edges, leave * -1.0, STATIONS)?;
|
||||
// The two chains are sampled from their own starts: turn one round when that pairs the
|
||||
// stations better (a match is built station to station).
|
||||
let direct: f64 = near_frames.iter().zip(&far_frames).map(|(a, b)| a.p.dist(b.p)).sum();
|
||||
let reversed: f64 = near_frames.iter().zip(far_frames.iter().rev()).map(|(a, b)| a.p.dist(b.p)).sum();
|
||||
if reversed < direct {
|
||||
near_frames.reverse();
|
||||
}
|
||||
guard("surface match", || {
|
||||
// The crown is the free knob: keep the one whose surface stays closest to the original.
|
||||
let mut best: Option<(f64, mt::Face)> = None;
|
||||
for crown_near in CROWNS {
|
||||
for crown_far in CROWNS {
|
||||
let made = bridge::bridge(&near_frames, &far_frames, [order, Continuity::G1], [crown_near, crown_far])?;
|
||||
let Some(made) = made.solid.face_iter().next().cloned() else {
|
||||
return Err(KernelError::Failed("match: the bridge made no face".into()));
|
||||
};
|
||||
let stray = deviation(&made, &face)?;
|
||||
if best.as_ref().is_none_or(|(b, _)| stray < *b) {
|
||||
best = Some((stray, made));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((_, made)) = best else {
|
||||
return Err(KernelError::Failed("match: no crown built a surface".into()));
|
||||
};
|
||||
let mut kept = made;
|
||||
// The rebuilt face takes the target's own sense at the seam: the two surfaces then meet
|
||||
// the same way round, which is what a stitch, a thicken or a display of the result reads.
|
||||
let seam = chain_mid(&chain)?;
|
||||
if let (Some((made_n, _)), Some((target_n, _))) = (normal_near(&kept, seam), normal_near(target_face, seam))
|
||||
&& made_n.dot(target_n) < 0.0
|
||||
{
|
||||
kept.invert();
|
||||
}
|
||||
let mut rec = Rebuild::default();
|
||||
rec.face(&kept, &face);
|
||||
Ok(crate::record::rebuilt_body(surface_body(vec![kept]), &[&body.solid, &target.solid], &rec))
|
||||
})
|
||||
}
|
||||
|
||||
/// How well the rebuilt surface `made` meets `target` along the chain picked by `target_pick`:
|
||||
/// the worst distance from the seam to that chain, the worst angle between the two surfaces'
|
||||
/// normals across it (degrees), and the highest continuity the seam holds
|
||||
/// ([`Continuity::G0`] when even the normals disagree).
|
||||
pub fn seam_continuity(made: &Body, target: &Body, target_pick: &[Vec3]) -> Result<(f64, f64, Continuity)> {
|
||||
made.require_brep("match")?;
|
||||
target.require_brep("match")?;
|
||||
let chain = bridge::chain(target, target_pick)?;
|
||||
let poly = chain_polyline(&chain)?;
|
||||
// The face whose boundary runs along the chain: the one nearest it, so that a body that has
|
||||
// not been matched yet — where the seam is still some way off — can be measured too.
|
||||
let mid = chain_mid(&chain)?;
|
||||
let face = made
|
||||
.solid
|
||||
.face_iter()
|
||||
.filter_map(|f| face_distance(f, mid).map(|d| (d, f)))
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, f)| f.clone())
|
||||
.ok_or_else(|| KernelError::Failed("match: the body has no faces".into()))?;
|
||||
let near = nearest_side(&face, &chain)?;
|
||||
let far = near.opposite();
|
||||
let ranges = ranges(&face)?;
|
||||
let mid_far = Side::at(&face.surface(), &ranges, far, 0.5);
|
||||
let leave = (mid_far - chain_mid(&chain)?)
|
||||
.normalized()
|
||||
.ok_or_else(|| KernelError::Invalid("the seam and the far boundary coincide: there is nothing to measure".into()))?;
|
||||
let candidates = support_faces(target, &chain, leave)?;
|
||||
let Some((target_face, _)) = candidates.first() else {
|
||||
return Err(KernelError::Invalid("no face of the target holds the picked edges".into()));
|
||||
};
|
||||
let frames = bridge::frames(target_face, &chain, leave, STATIONS)?;
|
||||
let size = target.size();
|
||||
guard("surface match", || {
|
||||
let surf = face.surface();
|
||||
let (mut gap, mut deg, mut curv, mut bend) = (0.0f64, 0.0f64, 0.0f64, 0.0f64);
|
||||
for i in 0..STATIONS {
|
||||
let t = i as f64 / (STATIONS - 1) as f64;
|
||||
let p = Side::at(&surf, &ranges, near, t);
|
||||
gap = gap.max(chain_distance(&poly, p));
|
||||
// What the target says there, and what the rebuilt surface actually does there.
|
||||
let Some(frame) = frames.iter().min_by(|a, b| a.p.dist(p).total_cmp(&b.p.dist(p))) else {
|
||||
return Err(KernelError::Failed("match: the target has no frames".into()));
|
||||
};
|
||||
let (_, t_made, bend_made, change_made) = seam_shape(&surf, &ranges, near, t)?;
|
||||
if let Some((n_made, _)) = normal_near(&face, p)
|
||||
&& let Some((n_target, _)) = normal_near(target_face, p)
|
||||
{
|
||||
deg = deg.max(angle_deg(n_made, n_target));
|
||||
}
|
||||
let scale = frame.bend.len().max(bend_made.len()).max(1.0 / size);
|
||||
curv = curv.max((bend_made - frame.bend).len() / scale);
|
||||
// The change of curvature is what a surface can hold: only its normal part counts,
|
||||
// and it turns round with the direction of travel.
|
||||
let change = if t_made.dot(frame.leave) < 0.0 { change_made * -1.0 } else { change_made };
|
||||
let scale3 = frame.bend_change.len().max(change.len()).max(scale / size);
|
||||
bend = bend.max((change - frame.bend_change).len() / scale3);
|
||||
}
|
||||
let mut order = Continuity::G0;
|
||||
if deg <= G1_DEG {
|
||||
order = Continuity::G1;
|
||||
if curv <= G2_REL {
|
||||
order = Continuity::G2;
|
||||
if bend <= G3_REL {
|
||||
order = Continuity::G3;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((gap, deg, order))
|
||||
})
|
||||
}
|
||||
|
||||
/// Which side of a face's parameter rectangle a boundary runs along: the match reads its face as
|
||||
/// a tensor patch, so its four sides are its own parameter lines.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Side {
|
||||
/// The `u = u0` side (its points run along `v`).
|
||||
U0,
|
||||
/// The `u = u1` side.
|
||||
U1,
|
||||
/// The `v = v0` side (its points run along `u`).
|
||||
V0,
|
||||
/// The `v = v1` side.
|
||||
V1,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
/// The four sides, in a fixed order.
|
||||
const ALL: [Side; 4] = [Side::U0, Side::U1, Side::V0, Side::V1];
|
||||
|
||||
/// The side opposite this one: what a match keeps when this one moves.
|
||||
fn opposite(self) -> Side {
|
||||
match self {
|
||||
Side::U0 => Side::U1,
|
||||
Side::U1 => Side::U0,
|
||||
Side::V0 => Side::V1,
|
||||
Side::V1 => Side::V0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this side pins `u` (its points run along `v`).
|
||||
fn is_u(self) -> bool {
|
||||
matches!(self, Side::U0 | Side::U1)
|
||||
}
|
||||
|
||||
/// The value this side pins, and the range the side runs over.
|
||||
fn fixed_and_along(self, u: (f64, f64), v: (f64, f64)) -> (f64, (f64, f64)) {
|
||||
match self {
|
||||
Side::U0 => (u.0, v),
|
||||
Side::U1 => (u.1, v),
|
||||
Side::V0 => (v.0, u),
|
||||
Side::V1 => (v.1, u),
|
||||
}
|
||||
}
|
||||
|
||||
/// The point at the normalized station `t` along the side.
|
||||
fn at(surf: &mt::Surface, ranges: &[(f64, f64); 2], side: Side, t: f64) -> Vec3 {
|
||||
let (u, v) = (ranges[0], ranges[1]);
|
||||
let (fixed, along) = side.fixed_and_along(u, v);
|
||||
let t = along.0 + (along.1 - along.0) * t.clamp(0.0, 1.0);
|
||||
if side.is_u() { from_p3(surf.subs(fixed, t)) } else { from_p3(surf.subs(t, fixed)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounded parameter ranges of a face's surface.
|
||||
fn ranges(face: &mt::Face) -> Result<[(f64, f64); 2]> {
|
||||
let surf = face.surface();
|
||||
let (Some(u), Some(v)) = surf.try_range_tuple() else {
|
||||
return Err(KernelError::Failed("match: the face's surface is unbounded".into()));
|
||||
};
|
||||
Ok([u, v])
|
||||
}
|
||||
|
||||
/// How far `p` is from the face's own surface.
|
||||
fn face_distance(face: &mt::Face, p: Vec3) -> Option<f64> {
|
||||
let surf = face.surface();
|
||||
let (u, v) = surf.search_nearest_parameter(p3(p), None, 100)?;
|
||||
Some(from_p3(surf.subs(u, v)).dist(p))
|
||||
}
|
||||
|
||||
/// The surface's unit normal at the point nearest `p`, and how far `p` is from that point.
|
||||
fn normal_near(face: &mt::Face, p: Vec3) -> Option<(Vec3, f64)> {
|
||||
let surf = face.surface();
|
||||
let (u, v) = surf.search_nearest_parameter(p3(p), None, 100)?;
|
||||
let n = surf.normal(u, v);
|
||||
let n = Vec3::new(n.x, n.y, n.z).normalized()?;
|
||||
Some((n, from_p3(surf.subs(u, v)).dist(p)))
|
||||
}
|
||||
|
||||
/// The face of `body` the picks lie on: they must all pick the same one. A pick is a point the
|
||||
/// caller clicked, so the face nearest it within the body's own pick tolerance is the face it
|
||||
/// means (`crate::surfaces::edge_near` reads points the same way).
|
||||
fn face_near(body: &Body, picks: &[Vec3]) -> Result<mt::Face> {
|
||||
if picks.is_empty() || picks.len() > MAX_PICKS || picks.iter().any(|p| !p.is_finite()) {
|
||||
return Err(KernelError::Invalid(format!("pick 1 to {MAX_PICKS} points on the face to match")));
|
||||
}
|
||||
let tol = body.size() * 0.05 + 1e-6;
|
||||
let mut found: Option<mt::Face> = None;
|
||||
for p in picks {
|
||||
let hit = body
|
||||
.solid
|
||||
.face_iter()
|
||||
.filter_map(|f| face_distance(f, *p).map(|d| (d, f)))
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.filter(|(d, _)| *d <= tol)
|
||||
.map(|(_, f)| f.clone())
|
||||
.ok_or_else(|| KernelError::Invalid(format!("no face of the body passes through {p:?}")))?;
|
||||
if found.as_ref().is_some_and(|f| f.id() != hit.id()) {
|
||||
return Err(KernelError::Invalid("the picks are on different faces: match one face".into()));
|
||||
}
|
||||
found = Some(hit);
|
||||
}
|
||||
found.ok_or_else(|| KernelError::Invalid("pick a point on the face to match".into()))
|
||||
}
|
||||
|
||||
/// A chain of edges as a polyline (each edge sampled), for lengths and distances.
|
||||
fn chain_polyline(chain: &[mt::Edge]) -> Result<Vec<Vec3>> {
|
||||
if chain.is_empty() {
|
||||
return Err(KernelError::Invalid("the chain has no edges".into()));
|
||||
}
|
||||
let mut out = Vec::with_capacity(chain.len() * (PER_EDGE + 1));
|
||||
for e in chain {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
if !(t1 > t0) || ![t0, t1].into_iter().all(f64::is_finite) {
|
||||
return Err(KernelError::Failed("match: an edge has no parameter range".into()));
|
||||
}
|
||||
for k in 0..=PER_EDGE {
|
||||
out.push(from_p3(c.subs(t0 + (t1 - t0) * k as f64 / PER_EDGE as f64)));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The point at the middle of a chain, by length.
|
||||
fn chain_mid(chain: &[mt::Edge]) -> Result<Vec3> {
|
||||
let poly = chain_polyline(chain)?;
|
||||
let mut cum = Vec::with_capacity(poly.len());
|
||||
cum.push(0.0);
|
||||
for w in poly.windows(2) {
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
|
||||
let Some(last) = cum.last().copied() else { break };
|
||||
cum.push(last + a.dist(*b));
|
||||
}
|
||||
let total = cum.last().copied().unwrap_or(0.0);
|
||||
if !(total > 1e-9) || !total.is_finite() {
|
||||
return Err(KernelError::Invalid("the picked chain has no length".into()));
|
||||
}
|
||||
let half = total * 0.5;
|
||||
let hi = cum.partition_point(|x| *x < half).clamp(1, poly.len().saturating_sub(1));
|
||||
let (Some(a), Some(b)) = (poly.get(hi - 1), poly.get(hi)) else {
|
||||
return Err(KernelError::Failed("match: chain".into()));
|
||||
};
|
||||
let (Some(&lo), Some(&up)) = (cum.get(hi - 1), cum.get(hi)) else {
|
||||
return Err(KernelError::Failed("match: chain".into()));
|
||||
};
|
||||
let span = up - lo;
|
||||
let t = if span > 1e-12 { (half - lo) / span } else { 0.0 };
|
||||
Ok(*a + (*b - *a) * t)
|
||||
}
|
||||
|
||||
/// How far `p` is from a polyline.
|
||||
fn chain_distance(poly: &[Vec3], p: Vec3) -> f64 {
|
||||
let mut best = f64::INFINITY;
|
||||
for w in poly.windows(2) {
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
|
||||
let d = b.dist(*a);
|
||||
let t = if d > 1e-12 { ((p - *a).dot(*b - *a) / (d * d)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
best = best.min(p.dist(*a + (*b - *a) * t));
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// The side of the face's own parameter rectangle the picked chain sits nearest.
|
||||
fn nearest_side(face: &mt::Face, chain: &[mt::Edge]) -> Result<Side> {
|
||||
let surf = face.surface();
|
||||
let ranges = ranges(face)?;
|
||||
let poly = chain_polyline(chain)?;
|
||||
/// Samples of each side used to tell which one the chain runs along.
|
||||
const SAMPLES: usize = 17;
|
||||
let mut best: Option<(f64, Side)> = None;
|
||||
for side in Side::ALL {
|
||||
let mut total = 0.0;
|
||||
for k in 0..SAMPLES {
|
||||
let t = k as f64 / (SAMPLES - 1) as f64;
|
||||
total += chain_distance(&poly, Side::at(&surf, &ranges, side, t));
|
||||
}
|
||||
let mean = total / SAMPLES as f64;
|
||||
if best.is_none_or(|(b, _)| mean < b) {
|
||||
best = Some((mean, side));
|
||||
}
|
||||
}
|
||||
best.map(|(_, s)| s).ok_or_else(|| KernelError::Failed("match: the face has no sides".into()))
|
||||
}
|
||||
|
||||
/// The face's own edges that run along a side of its parameter rectangle, chained head to tail.
|
||||
/// A match keeps the far side's own edges, so they must be a chain that runs on.
|
||||
fn side_edges(face: &mt::Face, side: Side, tol: f64) -> Result<Vec<mt::Edge>> {
|
||||
let surf = face.surface();
|
||||
let ranges = ranges(face)?;
|
||||
let (fixed, along) = side.fixed_and_along(ranges[0], ranges[1]);
|
||||
let band = (along.1 - along.0).abs().max(1e-12) * 1e-3;
|
||||
let mut pool: Vec<mt::Edge> = Vec::new();
|
||||
for e in face.edge_iter() {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let p = from_p3(c.subs(0.5 * (t0 + t1)));
|
||||
let Some((u, v)) = surf.search_nearest_parameter(p3(p), None, 100) else { continue };
|
||||
if from_p3(surf.subs(u, v)).dist(p) > tol {
|
||||
continue;
|
||||
}
|
||||
let got = if side.is_u() { u } else { v };
|
||||
if (got - fixed).abs() <= band {
|
||||
pool.push(e.clone());
|
||||
}
|
||||
}
|
||||
if pool.is_empty() {
|
||||
return Err(KernelError::Invalid("the face has no edges along the boundary a match would keep".into()));
|
||||
}
|
||||
let first = pool.remove(0);
|
||||
let mut chain = vec![first];
|
||||
let mut end = match chain.first() {
|
||||
Some(e) => e.back().clone(),
|
||||
None => return Err(KernelError::Failed("match: chain".into())),
|
||||
};
|
||||
while let Some(k) = pool.iter().position(|e| e.front().id() == end.id() || e.back().id() == end.id()) {
|
||||
let e = pool.remove(k);
|
||||
let e = if e.front().id() == end.id() { e } else { e.inverse() };
|
||||
end = e.back().clone();
|
||||
chain.push(e);
|
||||
}
|
||||
let mut start = match chain.first() {
|
||||
Some(e) => e.front().clone(),
|
||||
None => return Err(KernelError::Failed("match: chain".into())),
|
||||
};
|
||||
while let Some(k) = pool.iter().position(|e| e.front().id() == start.id() || e.back().id() == start.id()) {
|
||||
let e = pool.remove(k);
|
||||
let e = if e.back().id() == start.id() { e } else { e.inverse() };
|
||||
start = e.front().clone();
|
||||
chain.insert(0, e);
|
||||
}
|
||||
if !pool.is_empty() {
|
||||
return Err(KernelError::Invalid("the face's edges along that boundary do not run on from one another".into()));
|
||||
}
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
/// The faces of `target` that meet the chain and whose tangent plane holds the leave direction,
|
||||
/// best first: the face a match continues from, and the ones it might have been read from.
|
||||
fn support_faces(target: &Body, chain: &[mt::Edge], leave: Vec3) -> Result<Vec<(mt::Face, f64)>> {
|
||||
let mid = chain_mid(chain)?;
|
||||
let tol = target.size() * 1e-6 + 1e-9;
|
||||
let mut out: Vec<(mt::Face, f64)> = Vec::new();
|
||||
for face in target.solid.face_iter() {
|
||||
if !chain.iter().all(|e| face.edge_iter().any(|f| f.id() == e.id())) {
|
||||
continue;
|
||||
}
|
||||
let Some((n, d)) = normal_near(face, mid) else { continue };
|
||||
if d > tol {
|
||||
continue;
|
||||
}
|
||||
let score = (leave - n * leave.dot(n)).len();
|
||||
if score.is_finite() && score >= 0.2 {
|
||||
out.push((face.clone(), score));
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(KernelError::Invalid(
|
||||
"no face of the target holds the picked edges in the direction the match leaves in: pick the chain of a face the surface can continue"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
out.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The highest order the target itself holds along the chain: every face that meets those edges
|
||||
/// and whose tangent plane holds the leave direction reads the chain's shape from its own
|
||||
/// surface, and where two of them part company the target's own geometry jumps at the seam.
|
||||
fn supply_order(candidates: &[(mt::Face, f64)], chain: &[mt::Edge], leave: Vec3, size: f64) -> Result<Continuity> {
|
||||
let Some((first, best)) = candidates.first() else {
|
||||
return Err(KernelError::Invalid("no face of the target holds the picked edges".into()));
|
||||
};
|
||||
let keep: Vec<&mt::Face> = candidates.iter().filter(|(_, s)| *s >= 0.5 * best).map(|(f, _)| f).collect();
|
||||
let mut order = Continuity::G3;
|
||||
if keep.len() < 2 {
|
||||
return Ok(order);
|
||||
}
|
||||
let primary: Vec<Frame> = bridge::frames(first, chain, leave, STATIONS)?;
|
||||
for other in keep.iter().skip(1) {
|
||||
// A face that cannot be read along the whole chain says nothing here: the face the match
|
||||
// is built on is the one that must be able to supply it.
|
||||
let Ok(other_frames) = bridge::frames(other, chain, leave, STATIONS) else { continue };
|
||||
let mut worst_deg = 0.0f64;
|
||||
let mut worst = 0.0f64;
|
||||
let mut worst_change = 0.0f64;
|
||||
for (a, b) in primary.iter().zip(&other_frames) {
|
||||
if let (Some((na, _)), Some((nb, _))) = (normal_near(first, a.p), normal_near(other, b.p)) {
|
||||
worst_deg = worst_deg.max(angle_deg(na, nb));
|
||||
}
|
||||
let scale = a.bend.len().max(b.bend.len()).max(1.0 / size);
|
||||
worst = worst.max((a.bend - b.bend).len() / scale);
|
||||
let scale3 = a.bend_change.len().max(b.bend_change.len()).max(scale / size);
|
||||
worst_change = worst_change.max((a.bend_change - b.bend_change).len() / scale3);
|
||||
}
|
||||
if worst_deg > SUPPLY_DEG {
|
||||
order = Continuity::G0;
|
||||
}
|
||||
if worst > SUPPLY_REL && order.conditions() > 1 {
|
||||
order = Continuity::G1;
|
||||
}
|
||||
if worst_change > SUPPLY_BEND_REL && order.conditions() > 2 {
|
||||
order = Continuity::G2;
|
||||
}
|
||||
}
|
||||
Ok(order)
|
||||
}
|
||||
|
||||
/// The acute angle between two directions, in degrees. For two surfaces' normals this is the
|
||||
/// angle between their tangent planes: a sheet has no inside, so which way a normal points says
|
||||
/// nothing about whether the surfaces meet smoothly.
|
||||
fn angle_deg(a: Vec3, b: Vec3) -> f64 {
|
||||
let d = a.dot(b) / (a.len() * b.len());
|
||||
if !d.is_finite() {
|
||||
return f64::NAN;
|
||||
}
|
||||
d.abs().clamp(0.0, 1.0).acos().to_degrees()
|
||||
}
|
||||
|
||||
/// The geometric shape of a surface curve at a point from its first three derivatives along the
|
||||
/// curve: the same statement [`crate::bridge`] makes of a support surface — the unit tangent, the
|
||||
/// curvature vector, and the arclength derivative of that curvature vector.
|
||||
fn shape(d1: Vec3, d2: Vec3, d3: Vec3) -> Result<(Vec3, Vec3, Vec3)> {
|
||||
let sigma = d1.len();
|
||||
if !(sigma > 1e-12) || !sigma.is_finite() {
|
||||
return Err(KernelError::Failed("match: the seam's cross-section does not move".into()));
|
||||
}
|
||||
let t = d1 * (1.0 / sigma);
|
||||
let tp = d2 * (1.0 / sigma) - d1 * (d1.dot(d2) / (sigma * sigma * sigma));
|
||||
let nn = d2 - t * d2.dot(t);
|
||||
let np = d3 - t * d3.dot(t) - tp * d2.dot(t) - t * d2.dot(tp);
|
||||
let kappa = np * (sigma * sigma) - nn * (2.0 * d1.dot(d2));
|
||||
let change = kappa * (1.0 / (sigma.powi(5)));
|
||||
if ![t, nn, change].into_iter().all(Vec3::is_finite) {
|
||||
return Err(KernelError::Failed("match: the seam's shape is not finite".into()));
|
||||
}
|
||||
Ok((t, nn * (1.0 / (sigma * sigma)), change))
|
||||
}
|
||||
|
||||
/// The shape of a face's surface along its cross direction at the station `t`, read inwards from
|
||||
/// the boundary `near` over five one-sided samples: the point, the unit tangent, the curvature
|
||||
/// vector and the change of that curvature. The same finite difference [`crate::bridge::frames`]
|
||||
/// reads a support surface with, so that a rebuilt seam can be compared with what it matched.
|
||||
fn seam_shape(surf: &mt::Surface, ranges: &[(f64, f64); 2], near: Side, t: f64) -> Result<(Vec3, Vec3, Vec3, Vec3)> {
|
||||
let (station, cross, at_start) = if near.is_u() {
|
||||
(ranges[1].0 + (ranges[1].1 - ranges[1].0) * t, ranges[0], near == Side::U0)
|
||||
} else {
|
||||
(ranges[0].0 + (ranges[0].1 - ranges[0].0) * t, ranges[1], near == Side::V0)
|
||||
};
|
||||
let span = (cross.1 - cross.0).abs().max(1e-12);
|
||||
let h = span * 1e-3;
|
||||
let c0 = if at_start { cross.0 } else { cross.1 };
|
||||
let sgn = if at_start { 1.0 } else { -1.0 };
|
||||
let at = |x: f64| if near.is_u() { from_p3(surf.subs(x, station)) } else { from_p3(surf.subs(station, x)) };
|
||||
let q: Vec<Vec3> = (0..5).map(|k| at(c0 + sgn * k as f64 * h)).collect();
|
||||
let (Some(&q0), Some(&q1), Some(&q2), Some(&q3), Some(&q4)) = (q.first(), q.get(1), q.get(2), q.get(3), q.get(4)) else {
|
||||
return Err(KernelError::Failed("match: seam samples".into()));
|
||||
};
|
||||
let d1 = (q0 * -25.0 + q1 * 48.0 + q2 * -36.0 + q3 * 16.0 + q4 * -3.0) * (sgn / (12.0 * h));
|
||||
let d2 = (q0 * 35.0 + q1 * -104.0 + q2 * 114.0 + q3 * -56.0 + q4 * 11.0) * (1.0 / (12.0 * h * h));
|
||||
let d3 = (q0 * -5.0 + q1 * 18.0 + q2 * -24.0 + q3 * 14.0 + q4 * -3.0) * (sgn / (2.0 * h * h * h));
|
||||
let (t, bend, change) = shape(d1, d2, d3)?;
|
||||
Ok((q0, t, bend, change))
|
||||
}
|
||||
|
||||
/// How far the rebuilt surface strays from the original one: the root mean square of the
|
||||
/// distances from a grid of its samples to the original surface.
|
||||
fn deviation(made: &mt::Face, original: &mt::Face) -> Result<f64> {
|
||||
let (surf, os) = (made.surface(), original.surface());
|
||||
let ranges = ranges(made)?;
|
||||
let mut sum = 0.0f64;
|
||||
let mut n = 0usize;
|
||||
for i in 0..GRID {
|
||||
for j in 0..GRID {
|
||||
let u = ranges[0].0 + (ranges[0].1 - ranges[0].0) * i as f64 / (GRID - 1) as f64;
|
||||
let v = ranges[1].0 + (ranges[1].1 - ranges[1].0) * j as f64 / (GRID - 1) as f64;
|
||||
let q = from_p3(surf.subs(u, v));
|
||||
let Some((a, b)) = os.search_nearest_parameter(p3(q), None, 100) else { continue };
|
||||
let d = from_p3(os.subs(a, b)).dist(q);
|
||||
if d.is_finite() {
|
||||
sum += d * d;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return Err(KernelError::Failed("match: the original surface cannot be sampled".into()));
|
||||
}
|
||||
Ok((sum / n as f64).sqrt())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "matchsurf_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Tests of surface matching: a sheet is rebuilt to meet a cylinder's wall up to G1, G2 and G3,
|
||||
//! its seam, its far boundary and its interior are measured, and what cannot be matched is
|
||||
//! refused.
|
||||
|
||||
use super::*;
|
||||
use crate::bridge::{Continuity, Frame};
|
||||
use crate::build::SEAM_ANGLE;
|
||||
use crate::{cylinder, stitch};
|
||||
use solidcraft_geom::Vec3;
|
||||
use truck_modeling as mt;
|
||||
|
||||
/// The radius of the cylinder a sheet is matched to, the radius the sheet starts at, its height,
|
||||
/// and how far round the cylinder the sheet runs.
|
||||
const R_TARGET: f64 = 10.0;
|
||||
const R_SHEET: f64 = 11.0;
|
||||
const HEIGHT: f64 = 30.0;
|
||||
const SPAN: f64 = 0.6;
|
||||
|
||||
/// A point on the vertical line at radius `r`, angle `a`, height `z` (Z up).
|
||||
fn at(a: f64, r: f64, z: f64) -> Vec3 {
|
||||
Vec3::new(r * a.cos(), r * a.sin(), z)
|
||||
}
|
||||
|
||||
/// The outward radial and the circumferential unit directions at the angle `a`.
|
||||
fn radial(a: f64) -> Vec3 {
|
||||
Vec3::new(a.cos(), a.sin(), 0.0)
|
||||
}
|
||||
|
||||
fn circ(a: f64) -> Vec3 {
|
||||
Vec3::new(-a.sin(), a.cos(), 0.0)
|
||||
}
|
||||
|
||||
/// A cylindrical sheet of radius `r` between the angles `a0` and `a1`, `stations` high: the body
|
||||
/// a match rebuilds. Its near boundary is the vertical line at `a0`, its far boundary at `a1`.
|
||||
fn cylinder_sheet(r: f64, a0: f64, a1: f64, stations: usize) -> Body {
|
||||
let mut a = Vec::new();
|
||||
let mut b = Vec::new();
|
||||
for i in 0..stations {
|
||||
let z = HEIGHT * i as f64 / (stations - 1) as f64;
|
||||
a.push(Frame { p: at(a0, r, z), leave: circ(a0), bend: radial(a0) * (-1.0 / r), bend_change: Vec3::ZERO });
|
||||
b.push(Frame { p: at(a1, r, z), leave: circ(a1) * -1.0, bend: radial(a1) * (-1.0 / r), bend_change: Vec3::ZERO });
|
||||
}
|
||||
bridge::bridge(&a, &b, [Continuity::G2, Continuity::G2], [0.0, 0.0]).unwrap()
|
||||
}
|
||||
|
||||
/// The stock cylinder, and two picks on the vertical seam line of its wall.
|
||||
fn cylinder_and_picks() -> (Body, [Vec3; 2]) {
|
||||
let c = cylinder(Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0), R_TARGET, HEIGHT).unwrap();
|
||||
(c, [at(SEAM_ANGLE, R_TARGET, 6.0), at(SEAM_ANGLE, R_TARGET, 24.0)])
|
||||
}
|
||||
|
||||
/// The sheet of the usual shape, and a pick on its near boundary.
|
||||
fn sheet_and_pick() -> (Body, [Vec3; 1]) {
|
||||
(cylinder_sheet(R_SHEET, SEAM_ANGLE, SEAM_ANGLE + SPAN, 9), [at(SEAM_ANGLE, R_SHEET, HEIGHT * 0.5)])
|
||||
}
|
||||
|
||||
/// The middle of the edge of `body`'s first face nearest `p`.
|
||||
fn edge_at(body: &Body, p: Vec3) -> mt::Edge {
|
||||
let face = body.solid.face_iter().next().unwrap();
|
||||
let mut best: Option<(f64, mt::Edge)> = None;
|
||||
for e in face.edge_iter() {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let m = from_p3(c.subs(0.5 * (t0 + t1)));
|
||||
let d = m.dist(p);
|
||||
if best.as_ref().is_none_or(|(b, _)| d < *b) {
|
||||
best = Some((d, e.clone()));
|
||||
}
|
||||
}
|
||||
best.unwrap().1
|
||||
}
|
||||
|
||||
/// How far the rebuilt face's far boundary lies from the original one, in mm: the far row of the
|
||||
/// rebuilt surface sampled at its own stations, against the original boundary edge's own points.
|
||||
fn far_drift(made: &Body, sheet: &Body, far_mid: Vec3) -> f64 {
|
||||
let face = made.solid.face_iter().next().unwrap();
|
||||
let surf = face.surface();
|
||||
let (Some((u0, u1)), Some((_, v1))) = surf.try_range_tuple() else { panic!("unbounded") };
|
||||
let edge = edge_at(sheet, far_mid);
|
||||
let poly = chain_polyline(&[edge]).unwrap();
|
||||
let mut worst = 0.0f64;
|
||||
for k in 0..=8 {
|
||||
let p = from_p3(surf.subs(u0 + (u1 - u0) * k as f64 / 8.0, v1));
|
||||
worst = worst.max(chain_distance(&poly, p));
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
/// What the rebuilt sheet does at the normalized station `t` of its matching boundary: the
|
||||
/// point, the unit tangent, the curvature vector and the change of that curvature, read off the
|
||||
/// finished surface (never from the construction).
|
||||
fn seam_at(body: &Body, t: f64) -> (Vec3, Vec3, Vec3, Vec3) {
|
||||
let face = body.solid.face_iter().next().unwrap();
|
||||
let ranges = super::ranges(face).unwrap();
|
||||
super::seam_shape(&face.surface(), &ranges, Side::V0, t).unwrap()
|
||||
}
|
||||
|
||||
/// The stations a seam is measured at, as fractions of its length.
|
||||
fn stations() -> Vec<f64> {
|
||||
(0..9).map(|k| k as f64 / 8.0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_g2_match_meets_a_cylinder_with_its_own_curvature() {
|
||||
let (target, picks) = cylinder_and_picks();
|
||||
let (sheet, pick) = sheet_and_pick();
|
||||
let made = match_surface(&sheet, &pick, &target, &picks, Continuity::G2).unwrap();
|
||||
// The rebuilt boundary has moved onto the picked line: the seam is on the cylinder's wall.
|
||||
assert!(seam_at(&made, 0.5).0.dist(at(SEAM_ANGLE, R_TARGET, HEIGHT * 0.5)) < 1e-6, "the seam is on the picked line");
|
||||
let (gap, deg, order) = seam_continuity(&made, &target, &picks).unwrap();
|
||||
println!("g2: gap {gap:e} mm, normal {deg:e} deg, order {order:?}");
|
||||
assert!(gap < 1e-6, "position gap {gap:e}");
|
||||
assert!(deg < 0.01, "normal angle {deg}");
|
||||
assert!(order.conditions() >= Continuity::G2.conditions(), "{order:?}");
|
||||
// The curvature the cylinder bends with along the seam: matched to a thousandth of itself.
|
||||
let want = radial(SEAM_ANGLE) * (-1.0 / R_TARGET);
|
||||
let mut worst = 0.0f64;
|
||||
for t in stations() {
|
||||
worst = worst.max(seam_at(&made, t).2.dist(want) * R_TARGET);
|
||||
}
|
||||
println!("g2: worst curvature jump {worst:e} of the cylinder's own");
|
||||
assert!(worst < 1e-3, "curvature jump {worst:e}");
|
||||
// The far boundary did not move: it is built through the original boundary's own points and
|
||||
// stays on the original edge between them to round-off.
|
||||
let far = at(SEAM_ANGLE + SPAN, R_SHEET, HEIGHT * 0.5);
|
||||
let drift = far_drift(&made, &sheet, far);
|
||||
println!("g2: far boundary drift {drift:e} mm");
|
||||
assert!(drift < 1e-9, "the far boundary drifted {drift:e} mm");
|
||||
// The interior stays near the original: the crowning that leaves it closest.
|
||||
let (face, original) = (made.solid.face_iter().next().unwrap().clone(), sheet.solid.face_iter().next().unwrap().clone());
|
||||
let stray = deviation(&face, &original).unwrap();
|
||||
println!("g2: interior stray {stray:.6} mm rms");
|
||||
// The seam moved 1 mm, so the face cannot be closer than that at the seam itself: over the
|
||||
// whole face the root mean square distance is well under half of it.
|
||||
assert!(stray < 0.6, "the interior strays {stray} mm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_g1_match_holds_the_tangent_and_leaves_the_curvature_free() {
|
||||
let (target, picks) = cylinder_and_picks();
|
||||
let (sheet, pick) = sheet_and_pick();
|
||||
let made = match_surface(&sheet, &pick, &target, &picks, Continuity::G1).unwrap();
|
||||
let (gap, deg, order) = seam_continuity(&made, &target, &picks).unwrap();
|
||||
let want = radial(SEAM_ANGLE) * (-1.0 / R_TARGET);
|
||||
let worst = stations().into_iter().map(|t| seam_at(&made, t).2.dist(want) * R_TARGET).fold(0.0f64, f64::max);
|
||||
println!("g1: gap {gap:e} mm, normal {deg:e} deg, order {order:?}, curvature jump {worst:e}");
|
||||
assert!(gap < 1e-6 && deg < 0.01, "position {gap:e}, normal {deg}");
|
||||
assert_eq!(order, Continuity::G1, "a tangent match leaves the curvature free");
|
||||
assert!(worst > 1e-3, "a G1 match need not hold the curvature: {worst:e}");
|
||||
// The far boundary is kept whatever the order.
|
||||
let far = at(SEAM_ANGLE + SPAN, R_SHEET, HEIGHT * 0.5);
|
||||
assert!(far_drift(&made, &sheet, far) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_g3_match_holds_the_change_of_curvature() {
|
||||
let (target, picks) = cylinder_and_picks();
|
||||
let (sheet, pick) = sheet_and_pick();
|
||||
let made = match_surface(&sheet, &pick, &target, &picks, Continuity::G3).unwrap();
|
||||
let (gap, deg, order) = seam_continuity(&made, &target, &picks).unwrap();
|
||||
let want = radial(SEAM_ANGLE) * (-1.0 / R_TARGET);
|
||||
let (mut bend, mut change) = (0.0f64, 0.0f64);
|
||||
for t in stations() {
|
||||
let (_, _, b, c) = seam_at(&made, t);
|
||||
bend = bend.max(b.dist(want) * R_TARGET);
|
||||
// A cylinder's curvature does not change in its own surface: the normal part of the
|
||||
// change of curvature across the seam must be zero too.
|
||||
change = change.max(c.dot(radial(SEAM_ANGLE)).abs() * R_TARGET * R_TARGET);
|
||||
}
|
||||
println!("g3: gap {gap:e} mm, normal {deg:e} deg, order {order:?}, curvature jump {bend:e}, change {change:e}");
|
||||
assert!(gap < 1e-6 && deg < 0.01, "position {gap:e}, normal {deg}");
|
||||
assert_eq!(order, Continuity::G3, "the seam holds the change of curvature");
|
||||
assert!(bend < 1e-3, "curvature jump {bend:e}");
|
||||
assert!(change < 1e-3, "change of curvature {change:e}");
|
||||
let far = at(SEAM_ANGLE + SPAN, R_SHEET, HEIGHT * 0.5);
|
||||
assert!(far_drift(&made, &sheet, far) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_match_refuses_what_it_cannot_hold() {
|
||||
let (target, picks) = cylinder_and_picks();
|
||||
let (sheet, pick) = sheet_and_pick();
|
||||
// No pick on the body's face, no pick on the target's edges, and picks that are not numbers.
|
||||
assert!(match_surface(&sheet, &[], &target, &picks, Continuity::G1).is_err());
|
||||
assert!(match_surface(&sheet, &[Vec3::new(0.0, 0.0, 100.0)], &target, &picks, Continuity::G1).is_err());
|
||||
assert!(match_surface(&sheet, &[Vec3::new(f64::NAN, 0.0, 0.0)], &target, &picks, Continuity::G1).is_err());
|
||||
assert!(match_surface(&sheet, &vec![Vec3::ZERO; MAX_PICKS + 1], &target, &picks, Continuity::G1).is_err());
|
||||
assert!(match_surface(&sheet, &pick, &target, &[], Continuity::G1).is_err());
|
||||
// A pick that is not on the target at all, and one in the middle of its wall: no chain there.
|
||||
assert!(match_surface(&sheet, &pick, &target, &[Vec3::new(100.0, 0.0, 15.0)], Continuity::G1).is_err());
|
||||
assert!(match_surface(&sheet, &pick, &target, &[Vec3::new(10.0, 0.0, 15.0)], Continuity::G1).is_err());
|
||||
// A body that is not a surface: the stock cylinder itself.
|
||||
assert!(match_surface(&target, &[at(SEAM_ANGLE, R_TARGET, 15.0)], &target, &picks, Continuity::G1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_target_that_jumps_in_curvature_supplies_only_g1() {
|
||||
// Two sheets of the target meet along a line with the same tangent plane but different
|
||||
// curvature: a cylinder's wall and the plane tangent to it there. The target itself does not
|
||||
// hold the curvature along that line, so no match may be told to.
|
||||
let wall = cylinder_sheet(R_TARGET, SEAM_ANGLE, SEAM_ANGLE + 0.5, 5);
|
||||
let mut a = Vec::new();
|
||||
let mut b = Vec::new();
|
||||
for i in 0..5 {
|
||||
let z = HEIGHT * i as f64 / 4.0;
|
||||
let (p, q) = (at(SEAM_ANGLE, R_TARGET, z), at(SEAM_ANGLE, R_TARGET, z) + circ(SEAM_ANGLE) * 8.0);
|
||||
a.push(Frame { p, leave: circ(SEAM_ANGLE), bend: Vec3::ZERO, bend_change: Vec3::ZERO });
|
||||
b.push(Frame { p: q, leave: circ(SEAM_ANGLE) * -1.0, bend: Vec3::ZERO, bend_change: Vec3::ZERO });
|
||||
}
|
||||
let plane = bridge::bridge(&a, &b, [Continuity::G2, Continuity::G2], [0.0, 0.0]).unwrap();
|
||||
let joined = stitch(&[&wall, &plane], 1e-6).unwrap();
|
||||
assert_eq!(joined.solid.face_iter().count(), 2, "the two sheets sewed into one body");
|
||||
let picks = [at(SEAM_ANGLE, R_TARGET, 6.0), at(SEAM_ANGLE, R_TARGET, 24.0)];
|
||||
let (sheet, pick) = sheet_and_pick();
|
||||
// The seam is smooth enough to match, so G1 goes through; the curvature jumps there, so G2
|
||||
// and G3 do not.
|
||||
assert!(match_surface(&sheet, &pick, &joined, &picks, Continuity::G1).is_ok());
|
||||
let err = match_surface(&sheet, &pick, &joined, &picks, Continuity::G2).unwrap_err().to_string();
|
||||
println!("refused g2: {err}");
|
||||
assert!(err.contains("only g1"), "{err}");
|
||||
assert!(match_surface(&sheet, &pick, &joined, &picks, Continuity::G3).is_err());
|
||||
// The plain cylinder, whose two wall faces are the same surface, supplies G3.
|
||||
let (target, plain_picks) = cylinder_and_picks();
|
||||
assert!(match_surface(&sheet, &pick, &target, &plain_picks, Continuity::G3).is_ok());
|
||||
}
|
||||
@@ -1289,8 +1289,8 @@ impl Sketch {
|
||||
}
|
||||
|
||||
/// Exact boundary segments of a free-form curve: ellipses as four conics, conics, fit
|
||||
/// splines and control splines up to degree 3 as Béziers. None for other curves (and for
|
||||
/// higher-degree splines, which stay polylines).
|
||||
/// splines and control splines up to `MAX_BEZIER_DEGREE` as Béziers. None for other curves
|
||||
/// (and for higher-degree splines, which stay polylines).
|
||||
pub fn exact_segs(&self, ci: usize) -> Option<Vec<Seg2>> {
|
||||
let cu = self.curves.get(ci)?;
|
||||
let pt = |i: &usize| self.point(*i);
|
||||
@@ -1326,6 +1326,17 @@ impl Sketch {
|
||||
}
|
||||
}
|
||||
|
||||
/// The exact [`Seg2`] pieces of a curve of any kind, in the curve's own direction of travel:
|
||||
/// lines, circles and arcs from their analytic form, free-form curves through
|
||||
/// [`Sketch::exact_segs`]. Empty for a curve with no exact pieces (a spline above
|
||||
/// `solidcraft_geom::MAX_BEZIER_DEGREE`).
|
||||
pub fn exact_pieces(&self, ci: usize) -> Vec<Seg2> {
|
||||
match self.exact_segs(ci) {
|
||||
Some(segs) => segs,
|
||||
None => self.segs(ci),
|
||||
}
|
||||
}
|
||||
|
||||
/// Polyline of a free-form curve (ellipse, spline, conic); other curves: their segments'
|
||||
/// polylines.
|
||||
pub fn polyline(&self, ci: usize) -> Vec<Vec2> {
|
||||
@@ -1375,8 +1386,11 @@ impl Sketch {
|
||||
"e"
|
||||
}
|
||||
CurveKind::Spline { pts, control, degree, periodic } => {
|
||||
if pts.len() < 2 || pts.len() > crate::curves::MAX_SPLINE_POINTS || !(1..=7).contains(degree) {
|
||||
return Err(SketchError::Invalid("a spline needs 2 to 500 points and degree 1 to 7".into()));
|
||||
if pts.len() < 2 || pts.len() > crate::curves::MAX_SPLINE_POINTS || !(1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(degree) {
|
||||
return Err(SketchError::Invalid(format!(
|
||||
"a spline needs 2 to 500 points and degree 1 to {}",
|
||||
solidcraft_geom::MAX_BEZIER_DEGREE
|
||||
)));
|
||||
}
|
||||
if *periodic {
|
||||
if *control || *degree != 3 {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 602 KiB |
Reference in new issue
Block a user