surface.boundary: surfaces through boundary curves and curve networks (#763) (#2129)

* feat(geom): surfaces through curve networks, and spline spaces to build them

Adds spline_space (clamped B-spline spaces, Hermite interpolation, exact
embedding by Bézier degree elevation and knot insertion, a monotone cubic
for reparameterising) and network (loft, Coons and Gordon surfaces through
curves in one or two directions, with G1/G2 cross-boundary conditions
against a neighbouring face). Part of #763.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(kernel): boundary surfaces from sketch curves, 3D curves and edges

kernel::boundary_network turns sketch chains, 3D sketch polylines and edge chains
into network curves, gives G1 frames of the faces along open edges
(EdgeFaces), builds the B-spline face (sides split at chain vertices so a
join shares the body's edges) and joins it into a surface body by stitching.
G2 is refused for now (not supported yet). Part of #763.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kernel): second derivatives of B-spline surfaces in the vendored truck-geometry

BSplineSurface::uuder and vvder multiplied the second differences by p
instead of p·(p − 1), as BSplineCurve::der2 does, so every surface of
degree p ≥ 3 (NURBS too, through its homogeneous surface) reported
1/(p − 1) of its second derivative: a cubic surface's curvature read as
half. This is why a G2 boundary surface on a cylinder measured half the
curvature it carries (#763). New tests hold both to finite differences
for degrees 2 to 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(kernel): G2 boundary surfaces against a body's face

The network always carried the face's curvature across a G2 edge; the
refusal came from measuring it with truck's halved surface second
derivatives (fixed in the previous commit). EdgeFaces now reads the
face's second fundamental form from the exact second derivatives
instead of differencing the normal. A new test holds the curvature
across a cylinder's edge to the cylinder's within 0.1%, also with a
sheared far curve; G3 is refused (a network holds up to G2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* test(kernel): a boundary patch with straight corners measures its own area

A stadium's arcs meet its lines at 180°, so the Coons patch is singular
at its corners. From such a corner the reference mesher's boundary walk
ran onto the B-spline's extrapolation past its domain (a planar spline
passes through every point of its plane there), and the face measured
289 mm² for its 158.5 (#763). The vendored mesher's bounded projection
(nearest_projection, #2043) keeps the walk on the domain; this holds it
there, by the faces, the measuring mesh and the display mesh. The
display path (#1653) was never affected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(geom,kernel): three-sided boundary surfaces (one side collapsed to a point)

A network curve may be a PointCurve: the apex where the two sides beside
it meet. The curves through it cross it at the point itself, its row of
the net collapses there, and the kernel's face leaves that side out of
its loop (three edges, one vertex at the apex). One point per patch, as
a side; refused elsewhere, when it moves, or with a continuity
condition. Tests: a triangle and a quarter disc filled flat with their
analytic areas (40 mm², 25π), by geom and by the kernel's faces and
meshes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(doc): boundary surfaces through curve networks and loops of three or four

FeatureKind::SurfaceNetwork saves a network as `u` and `v` curves, each
a sketch chain (planar or 3D), a chain of a body's edges with an
optional G1/G2 continuity, or a point (the apex of a three-sided
patch), and an `operation` that can join the surface into the body of
its edges. A SurfaceBoundary of three or four sketch curves, refused
until now, builds as the flat Coons patch of its loop (three collapse
the side opposite the first curve). The format change is one pending
file; feature-walk goldens record the new variant's sketches, bodies
and points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(engine): surface.boundary through loops of three or four and curve networks

The command keeps its loop form (two to four curves of one sketch; three
and four now build the flat patch of the loop, crown refused there) and
gains a network form: `u` and `v`, each curve a sketch curve id, a
{sketch?, curves} chain (planar or 3D), a {body, edges, continuity?}
edge chain (g1/g2 with the face beside it) or a {point} for a
three-sided patch, with `operation` new or join. Edits may move between
the two forms (timeline.redefine accepts the command now that it has a
dialog).

Tests: analytic areas of loop patches and of a hyperbolic paraboloid
bounded by four 3D lines (every mesh vertex on z = xy/10), a loft
across two sketches, G1 and G2 measured across a cylinder's edge with
inspect.continuity (angle < 0.1°; curvature jump 1e-16 with G2 against
0.027 with G1), 26 hostile networks refused without panicking, and u/v
added to hostile_params_never_panic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(ui): Boundary Surface dialog

Curves and Cross curves take sketch curves and edges (picks that meet
end to end in one sketch or body are one chain); Continuity and
Operation (New Body or Join). Two to four sketch curves of one sketch
with nothing across are the loop patch; anything else is a network.
Edit Feature brings either back (per-side orders, crown and stations
the dialog does not show are kept). Strings in en, ja and zh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* docs: ROADMAP M8 and the bridge patch's notes for boundary networks (#763)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* test(oracle): a boundary surface through a loop, thickened (#763)

Case 83: four sketch curves (a square's three sides and a half circle
for its top) make one boundary surface, thickened 2 mm; analytic values
from tools/oracle-analytic/cases_surfaces.py (V = 2A, area 2A + 2P,
centroid by moments). Recipes gain boundary_surface, thicken and remove.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* refactor: boundary surfaces, review cleanups

Project onto the face once per derivative evaluation (the frame is
passed to natural() and reused by scale()), keep each track's
finite-difference step instead of rescanning its table, export the
kernel's edge-pick and chain caps and the default station count for
the engine, doc and dialog to share, map TooLarge spline errors to
KernelError::TooLarge, reuse the engine's operation parser, and skip
the dialog's sketch lookup when the picks are no loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-17 21:47:38 -04:00
1 parent ae6a33bf96
commit 83c31d77c4
37 files changed
+4358 -44

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
//! The Boundary Surface dialog (#763): a loop of sketch curves becomes the loop patch, edges and
//! curves across become a network with its continuity and operation, curves that meet end to end
//! are one chain, and Edit Feature brings a network back as it was.
use crate::dialogs::Kind;
use crate::dialogs_surface::Surf;
use crate::scenario::Harness;
use serde_json::{Map, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::{FeatureKind, Operation, kernel::Continuity};
use solidcraft_engine::geom::Vec3;
fn ok(h: &mut Harness) {
h.frames(2);
h.step(&json!({"widget":"OK"})).unwrap();
// An edit is applied off the UI thread: give it the frames it takes.
for _ in 0..200 {
if h.app.dialog.is_none() {
break;
}
h.frames(1);
}
assert!(h.app.dialog.is_none(), "{:?} {:?} {:?}", h.app.preview.error, h.app.dialog.as_ref().map(|d| (&d.error, &d.inputs)), h.app.session.log);
}
#[test]
fn a_loop_of_sketch_curves_is_the_loop_patch() {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY","name":"Loop"})).unwrap();
for (id, a, b) in [("s1", [0, 0], [10, 0]), ("s2", [10, 0], [10, 10]), ("s3", [10, 10], [0, 10]), ("s4", [0, 10], [0, 0])] {
h.app.run("sketch.line", json!({"points":[a, b],"ids":[id]})).unwrap();
}
h.app.run("sketch.finish", json!({})).unwrap();
h.step(&json!({"call":"ui.start","params":{"command":"surface.boundary"}})).unwrap();
h.frames(3);
assert!(h.widget("Cross curves").is_some() && h.widget("Continuity").is_some() && h.widget("Operation").is_some());
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = ["s1", "s2", "s3", "s4"].iter().map(|id| Sel::SketchCurve { id: (*id).into() }).collect();
ok(&mut h);
let f = h.app.session.doc.features.last().unwrap();
let FeatureKind::SurfaceBoundary { curves, orders, .. } = &f.kind else { panic!("{:?}", f.kind) };
assert_eq!(curves, &["s1", "s2", "s3", "s4"]);
assert!(orders.iter().all(|o| *o == Continuity::G1), "{orders:?}");
assert_eq!(h.app.session.world_state().bodies.len(), 1);
}
/// A quarter cylinder sheet (its top edge open at y = 10, z = 0) and a 3D line above it.
fn cylinder_and_line(h: &mut Harness) -> String {
h.app.run("sketch.create", json!({"plane":"YZ","name":"Arc"})).unwrap();
let c = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
let r = h.app.run("sketch.arc.three_point", json!({"start":[0,-10],"end":[10,0],"through":[c,-c]})).unwrap();
let arc = r["created"]["curves"][0].as_str().unwrap().to_string();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("surface.extrude", json!({"sketch":"Arc","curves":[arc],"distance":10})).unwrap();
h.app.run("sketch.create", json!({"plane":"XY","name":"Top"})).unwrap();
h.app.run("sketch.options", json!({"sketch_3d": true})).unwrap();
let r = h.app.run("sketch.line3d", json!({"points":[[0,3,12],[10,3,12]]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
r["curves"][0].as_str().unwrap().to_string()
}
#[test]
fn edges_and_curves_make_a_network_and_come_back_in_an_edit() {
let mut h = Harness::new();
let top = cylinder_and_line(&mut h);
let rim = crate::dialogs::edge_sel(&h.app.session, Vec3::new(5.0, 10.0, 0.0)).unwrap();
let Sel::Edge { body, .. } = &rim else { panic!("{rim:?}") };
let body = body.clone();
h.step(&json!({"call":"ui.start","params":{"command":"surface.boundary"}})).unwrap();
h.frames(3);
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = vec![rim.clone(), Sel::SketchCurve { id: top.clone() }];
let Kind::Surface(Surf::Boundary { continuity, join }) = &mut d.kind else { panic!("{:?}", d.kind) };
*continuity = 2;
*join = true;
ok(&mut h);
let name = h.app.session.doc.features.last().unwrap().name.clone();
let network = |h: &Harness| match &h.app.session.doc.find_feature(&name).unwrap().kind {
FeatureKind::SurfaceNetwork { u, v, operation } => (u.clone(), v.clone(), *operation),
k => panic!("{k:?}"),
};
let (u, v, operation) = network(&h);
assert_eq!((u.len(), v.len(), operation), (2, 0, Operation::Join));
assert_eq!(u[0].body.as_ref().map(|b| b.to_string()), Some(body.clone()));
assert_eq!(u[0].continuity, Continuity::G2);
assert_eq!(u[1].curves, vec![top.clone()]);
// Joined into the sheet: one body, two faces.
let st = h.app.session.world_state();
assert_eq!(st.bodies.len(), 1);
assert_eq!(st.body(&body).map(|b| b.body.face_count()), Some(2));
drop(st);
h.step(&json!({"call":"ui.editFeature","params":{"feature":name}})).unwrap();
h.frames(8);
let d = h.app.dialog.as_ref().unwrap();
assert!(matches!(d.kind, Kind::Surface(Surf::Boundary { continuity: 2, join: true })), "{:?}", d.kind);
assert_eq!(d.inputs[0].items.len(), 2, "{:?}", d.inputs[0].items);
ok(&mut h);
let (u2, v2, op2) = network(&h);
assert_eq!((u2.len(), v2.len(), op2), (2, 0, Operation::Join));
assert_eq!((u2[0].continuity, &u2[1].curves), (Continuity::G2, &vec![top]));
}
/// Picks that meet end to end in one sketch are one curve of the network; a pick elsewhere is
/// another.
#[test]
fn curves_that_meet_are_one_chain() {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY","name":"Net"})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,0],[5,0]],"ids":["a1"]})).unwrap();
h.app.run("sketch.line", json!({"points":[[5,0],[10,0]],"ids":["a2"]})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,8],[10,8]],"ids":["b"]})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,0],[0,8]],"ids":["c"]})).unwrap();
h.app.run("sketch.line", json!({"points":[[10,0],[10,8]],"ids":["d"]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
let sketch = h.app.session.doc.find_feature("Net").unwrap().id;
let k = Surf::Boundary { continuity: 0, join: false };
let (_, mut inputs) = crate::dialogs_surface::start(&h.app, "surface.boundary").unwrap();
inputs[0].items = ["a2", "b", "a1"].iter().map(|id| Sel::SketchCurve { id: (*id).into() }).collect();
inputs[1].items = ["c", "d"].iter().map(|id| Sel::SketchCurve { id: (*id).into() }).collect();
let cmds = crate::dialogs_surface::commands(&h.app, &k, &inputs, &Map::new()).unwrap();
let (id, p) = &cmds[0];
assert_eq!(id, "surface.boundary");
assert_eq!(p["u"], json!([{"sketch": sketch, "curves": ["a2", "a1"]}, {"sketch": sketch, "curves": ["b"]}]), "{p}");
assert_eq!(p["v"], json!([{"sketch": sketch, "curves": ["c"]}, {"sketch": sketch, "curves": ["d"]}]), "{p}");
// And it builds: the chain a1–a2 is one boundary of the patch.
h.app.run("surface.boundary", p.clone()).unwrap();
assert_eq!(h.app.session.world_state().bodies.len(), 1);
}
+199 -1
View File
@@ -1,5 +1,6 @@
//! Dialogs for the surface tools and face splitting (#19): Surface Extrude, Revolve, Sweep and
//! Loft, Offset and Ruled Surface, Patch, Stitch, Thicken, Extend, Trim, Untrim and Split Face. Each has a live preview and Edit Feature.
//! Loft, Offset and Ruled Surface, Boundary Surface, Patch, Stitch, Thicken, Extend, Trim, Untrim
//! and Split Face. Each has a live preview and Edit Feature.
use serde_json::{Map, Value, json};
use solidcraft_engine::Sel;
@@ -27,6 +28,13 @@ pub enum Surf {
},
Sweep(crate::dialogs_sweep::SweepOpts),
Loft,
/// `continuity`: an index into [`PATCH_CONTINUITY`] (for a loop of sketch curves, how much of
/// the sketch's plane each side repeats; for picked edges, how the surface meets the faces
/// beside them); `join`: sew the surface into the body of its edges.
Boundary {
continuity: usize,
join: bool,
},
/// `continuity`: an index into [`PATCH_CONTINUITY`] (how a patch on edges meets the faces
/// beside its loop).
Patch {
@@ -74,6 +82,7 @@ impl Surf {
Surf::Revolve { .. } => "SURFACE REVOLVE",
Surf::Sweep(_) => "SURFACE SWEEP",
Surf::Loft => "SURFACE LOFT",
Surf::Boundary { .. } => "BOUNDARY SURFACE",
Surf::Patch { .. } => "PATCH",
Surf::Unstitch => "UNSTITCH",
Surf::ReverseNormal => "REVERSE NORMAL",
@@ -104,6 +113,9 @@ impl Surf {
/// How a patch on edges meets the faces beside its loop (labels, and the `continuity` parameter).
const PATCH_CONTINUITY: [(&str, &str); 3] = [("Position (G0)", "g0"), ("Tangent (G1)", "g1"), ("Curvature (G2)", "g2")];
/// What a boundary surface becomes: a body of its own, or part of the body of its edges.
const BOUNDARY_OPS: [&str; 2] = ["New Body", "Join"];
/// Which holes Untrim takes away: every one, the picked ones, or none (the outer boundary only).
const UNTRIM_HOLES: [&str; 3] = ["All", "Picked", "None"];
@@ -128,6 +140,7 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
]
}
Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)],
Surf::Boundary { .. } => vec![SelInput::new("Curves", CURVES | EDGES, true), SelInput::new("Cross curves", CURVES | EDGES, true)],
Surf::Patch { .. } => vec![SelInput::new("Boundary", PROFILES | EDGES, true), SelInput::new("Pass through", VERTICES | FACES, true)],
Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)],
Surf::ReverseNormal => vec![SelInput::new("Surface, or faces to turn over", BODIES | FACES, true)],
@@ -155,6 +168,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
"surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None },
"surface.sweep" => Surf::Sweep(crate::dialogs_sweep::SweepOpts::default()),
"surface.loft" => Surf::Loft,
"surface.boundary" => Surf::Boundary { continuity: 1, join: false },
"surface.patch" => Surf::Patch { continuity: 0 },
"surface.unstitch" => Surf::Unstitch,
"surface.reverse_normal" => Surf::ReverseNormal,
@@ -264,6 +278,16 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
combo(ui, "pa_cont", &PATCH_CONTINUITY.map(|(label, _)| label), continuity);
ui.end_row();
}
Surf::Boundary { continuity, join } => {
row_label(ui, "Continuity");
combo(ui, "bo_cont", &PATCH_CONTINUITY.map(|(label, _)| label), continuity);
ui.end_row();
row_label(ui, "Operation");
let mut op = usize::from(*join);
combo(ui, "bo_op", &BOUNDARY_OPS, &mut op);
*join = op == 1;
ui.end_row();
}
Surf::Sweep(opts) => enter |= crate::dialogs_sweep::rows(ui, opts, "ss"),
Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Loft => {}
}
@@ -342,6 +366,98 @@ fn tool(app: &SolveApp, sel: Option<&Sel>) -> Result<(&'static str, Value), Stri
}
}
/// One curve of a boundary surface as the dialog picks it: sketch curves of one sketch, or edges
/// of one body, that meet end to end.
#[derive(Clone, Debug, PartialEq)]
enum Chain {
Sketch { sketch: u64, ids: Vec<String> },
Edges { body: String, points: Vec<Vec3> },
}
/// The picks grouped into curves: picks of one sketch (or one body) whose ends meet run on into
/// one chain, in the order they were first picked; anything else is a curve of its own.
fn chains(app: &SolveApp, picks: &[Sel]) -> Result<Vec<Chain>, String> {
let st = app.session.world_state();
// Each pick: what it belongs to (a sketch or a body), its ends, and how it joins a chain.
let mut parts: Vec<(Chain, [Vec3; 2])> = Vec::with_capacity(picks.len());
for x in picks {
match x {
Sel::SketchCurve { id } => {
let sketch = crate::dialogs::curves_sketch(app, std::slice::from_ref(id), None).ok_or("a picked curve is no longer in a sketch")?;
let ss = st.sketch(sketch).ok_or("a picked curve's sketch has not been solved")?;
let ends = if let Some(ci) = ss.sketch.curve_index(id) {
let segs = ss.sketch.segs(ci);
match (segs.first(), segs.last()) {
(Some(a), Some(b)) => [ss.plane.to_world(a.start()), ss.plane.to_world(b.end())],
_ => return Err(format!("curve `{id}` has no geometry")),
}
} else {
let w = ss.sketch.wire_index(id).and_then(|wi| ss.sketch.wires.get(wi)).ok_or("a picked curve is no longer in a sketch")?;
match (w.pts.first(), w.pts.last()) {
(Some(a), Some(b)) => [*a, *b],
_ => return Err(format!("curve `{id}` has no geometry")),
}
};
parts.push((Chain::Sketch { sketch, ids: vec![id.clone()] }, ends));
}
Sel::Edge { body, index, point } => {
let b = st.body(body).ok_or("a picked edge's body is gone")?;
let m = b.mesh();
let e = m.edges.get(*index).ok_or("a picked edge is gone")?;
let (Some(a), Some(z)) = (e.first(), e.last()) else { return Err("a picked edge has no points".into()) };
parts.push((Chain::Edges { body: body.clone(), points: vec![*point] }, [*a, *z]));
}
_ => return Err("pick sketch curves or edges".into()),
}
}
let same_source = |a: &Chain, b: &Chain| match (a, b) {
(Chain::Sketch { sketch: x, .. }, Chain::Sketch { sketch: y, .. }) => x == y,
(Chain::Edges { body: x, .. }, Chain::Edges { body: y, .. }) => x == y,
_ => false,
};
let meet = |a: &[Vec3; 2], b: &[Vec3; 2]| a.iter().any(|p| b.iter().any(|q| p.dist(*q) <= 1e-6 * (1.0 + p.len())));
// Union the picks that meet, keeping the first pick of each chain in front.
let mut group: Vec<usize> = (0..parts.len()).collect();
fn root(group: &[usize], mut i: usize) -> usize {
while let Some(&g) = group.get(i) {
if g == i {
break;
}
i = g;
}
i
}
for i in 0..parts.len() {
for j in 0..i {
let (Some((ci, ei)), Some((cj, ej))) = (parts.get(i), parts.get(j)) else { continue };
if same_source(ci, cj) && meet(ei, ej) {
let (ri, rj) = (root(&group, i), root(&group, j));
if let Some(g) = group.get_mut(ri.max(rj)) {
*g = ri.min(rj);
}
}
}
}
let mut out: Vec<(usize, Chain)> = Vec::new();
for (i, (part, _)) in parts.into_iter().enumerate() {
let r = root(&group, i);
match out.iter_mut().find(|(k, _)| *k == r) {
Some((_, Chain::Sketch { ids, .. })) => {
if let Chain::Sketch { ids: more, .. } = part {
ids.extend(more);
}
}
Some((_, Chain::Edges { points, .. })) => {
if let Chain::Edges { points: more, .. } = part {
points.extend(more);
}
}
None => out.push((r, part)),
}
}
Ok(out.into_iter().map(|(_, c)| c).collect())
}
/// The command a surface dialog makes.
pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
let need = |i: usize, what: &str| if items(inputs, i).is_empty() { Err(format!("select {what} first")) } else { Ok(()) };
@@ -407,6 +523,48 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
.collect::<Result<_, String>>()?;
("surface.loft", json!({ "sections": sections }))
}
Surf::Boundary { continuity, join } => {
need(0, "the curves (sketch curves or edges)")?;
let word = PATCH_CONTINUITY.get(*continuity).map_or("g0", |(_, w)| *w);
let across = items(inputs, 1);
let picks = items(inputs, 0);
let sketch_ids: Vec<String> =
picks.iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
// Two to four sketch curves of one sketch and nothing across: the loop patch.
let looped = across.is_empty() && !*join && sketch_ids.len() == picks.len() && (2..=4).contains(&picks.len());
match looped.then(|| crate::dialogs::curves_sketch(app, &sketch_ids, None)).flatten() {
Some(sk) => {
let mut p = json!({"sketch": sk, "curves": sketch_ids});
// Orders that differ from side to side (kept from the feature) stand as they are.
if !extra.contains_key("continuity") {
p["continuity"] = json!(word);
}
("surface.boundary", p)
}
None => {
let curve = |c: &Chain| match c {
Chain::Sketch { sketch, ids } => json!({"sketch": sketch, "curves": ids}),
Chain::Edges { body, points } => {
let mut v = json!({"body": body, "edges": points.iter().map(|p| pt(*p)).collect::<Vec<_>>()});
if *continuity > 0 {
v["continuity"] = json!(word);
}
v
}
};
let u: Vec<Value> = chains(app, picks)?.iter().map(curve).collect();
let v: Vec<Value> = chains(app, across)?.iter().map(curve).collect();
let mut p = json!({"u": u});
if !v.is_empty() {
p["v"] = json!(v);
}
if *join {
p["operation"] = json!("join");
}
("surface.boundary", p)
}
}
}
Surf::Patch { continuity } => {
need(0, "a boundary (sketch profiles or a loop of edges)")?;
let profiles: Vec<(u64, usize)> =
@@ -629,6 +787,46 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
],
)
}
FeatureKind::SurfaceBoundary { curves, orders, crown, samples, .. } => {
let first = orders.first().copied().unwrap_or(solidcraft_engine::doc::kernel::Continuity::G1);
let index = PATCH_CONTINUITY.iter().position(|(_, w)| *w == first.as_str());
// What the dialog does not show is kept as it was: a crown, the stations, and orders
// that differ from side to side or that the list does not offer (G3).
let mut extra = Map::new();
if *crown != 0.0 {
extra.insert("crown".into(), json!(crown));
}
if *samples != solidcraft_engine::doc::BOUNDARY_SAMPLES {
extra.insert("samples".into(), json!(samples));
}
if index.is_none() || orders.iter().any(|o| *o != first) {
extra.insert("continuity".into(), json!(orders.iter().map(|o| o.as_str()).collect::<Vec<_>>()));
}
let k = Surf::Boundary { continuity: index.unwrap_or(1), join: false };
let mut ins = inputs(&k);
if let Some(inp) = ins.first_mut() {
inp.items = curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
}
return Some((Kind::Surface(k), ins, extra));
}
FeatureKind::SurfaceNetwork { u, v, operation } => {
// A network with a point for a side has nothing to pick it with here.
if u.iter().chain(v).any(|c| c.point.is_some()) {
return None;
}
let sels = |list: &[solidcraft_engine::doc::NetworkCurve]| -> Vec<Sel> {
list.iter()
.flat_map(|c| {
let mut out: Vec<Sel> = c.curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
out.extend(c.edges.iter().filter_map(edge));
out
})
.collect()
};
let g = u.iter().chain(v).find(|c| c.body.is_some()).map_or(solidcraft_engine::doc::kernel::Continuity::G0, |c| c.continuity);
let index = PATCH_CONTINUITY.iter().position(|(_, w)| *w == g.as_str()).unwrap_or(0);
(Surf::Boundary { continuity: index, join: *operation == solidcraft_engine::doc::Operation::Join }, vec![sels(u), sels(v)])
}
FeatureKind::SurfaceLoft { sections, .. } => {
(Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch.0, sec.profiles.as_ref(), &sec.curves)).collect()])
}
+2
View File
@@ -106,6 +106,8 @@ mod history_off_thread_tests;
pub use file_picker::FilePickerFuture;
#[cfg(test)]
mod blend_arrow_tests;
#[cfg(test)]
mod boundary_dialog_tests;
pub(crate) mod display_lod;
#[cfg(test)]
mod file_load_ui_tests;
+2 -1
View File
@@ -25,7 +25,7 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
FeatureKind::SurfaceRuled { .. } => "surface_ruled",
FeatureKind::SurfaceUntrim { .. } => "surface_untrim",
FeatureKind::SurfaceBlend { .. } => "surface_blend",
FeatureKind::SurfaceBoundary { .. } => "surface_boundary",
FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. } => "surface_boundary",
FeatureKind::SurfaceMatch { .. } => "continuity",
FeatureKind::SurfaceFair { .. } => "surface_fair",
FeatureKind::Revolve { .. } => "revolve",
@@ -549,6 +549,7 @@ mod tests {
("surface.reverse_normal", json!({"type":"reverse_normal", "body":"A"})),
("surface.blend", json!({"type":"surface_blend", "from":{"body":"A", "edges":[]}, "to":{"body":"B", "edges":[]}})),
("surface.boundary", json!({"type":"surface_boundary", "sketch":0, "curves":[]})),
("surface.boundary", json!({"type":"surface_network", "u":[]})),
("surface.match", json!({"type":"surface_match", "body":"A", "target":"B", "edges":[]})),
("surface.fair", json!({"type":"surface_fair", "body":"A", "tolerance":"0.001"})),
("solid.rib", json!({"type":"rib", "sketch":0, "curves":[], "thickness":"2"})),