Class-A surfacing 2/3: surface match, boundary patch and CV fairing (#1487)

Thanks @midasdf.
This commit is contained in:
midasdf authored and GitHub committed 2026-10-16 03:26:20 -04:00
1 parent 7f79443b46
commit 07da7f8065
27 files changed
+3513 -11

No files matched your search

+1
View File
@@ -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 |
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "法線を反転",
+2
View File
@@ -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": "법선 반전",
+2
View File
@@ -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": "反转法向",
+82
View File
@@ -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",
+3
View File
@@ -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),
+136
View File
@@ -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.
+2
View File
@@ -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 {}
+2
View File
@@ -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());
+95 -7
View File
@@ -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());
}
}
+197
View File
@@ -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", &params).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");
}
}
+160
View File
@@ -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");
}
}
+123
View File
@@ -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;
+276
View File
@@ -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
+9
View File
@@ -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};
+601
View File
@@ -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;
+225
View File
@@ -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());
}
+18 -4
View File
@@ -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