* 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:
37 files changed
+4358
-44
No files matched your search
@@ -978,6 +978,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.chain(super::features_more::COMMANDS)
|
||||
.chain(super::construct::COMMANDS)
|
||||
.chain(super::surface::COMMANDS)
|
||||
.chain(super::surface_boundary::COMMANDS)
|
||||
.any(|candidate| candidate.id == spec.id)
|
||||
|| matches!(
|
||||
spec.id,
|
||||
@@ -1049,7 +1050,10 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
*after = before.clone();
|
||||
}
|
||||
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) {
|
||||
// A boundary surface's loop of sketch curves and its curve network are one command's two
|
||||
// forms: an edit may turn one into the other.
|
||||
let boundary = |k: &FeatureKind| matches!(k, FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. });
|
||||
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) && !(boundary(&new.kind) && boundary(&old.kind)) {
|
||||
return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid)));
|
||||
}
|
||||
// Optional inputs can move other inputs' positions (e.g. Distance 2 goes before Taper).
|
||||
|
||||
@@ -1,38 +1,56 @@
|
||||
//! 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.
|
||||
//! Boundary surfaces (`surface.boundary`, issue #763): one patch through two to four sketch
|
||||
//! curves in loop order, or one surface through a network of curves in two directions (sketch
|
||||
//! curves, 3D sketch curves, a body's edges, or a point for a three-sided patch). The kernel
|
||||
//! builds the face; this is the command's parameters and its checks, so that what it cannot hold
|
||||
//! is refused before anything is built.
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{FeatureKind, SketchId};
|
||||
use solidcraft_doc::{BodyName, FeatureKind, NetworkCurve, Operation, Position, SketchId};
|
||||
use solidcraft_kernel::Continuity;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, feature_sketch};
|
||||
use crate::params::{bad, string_list};
|
||||
use super::features::{add_feature, feature_sketch, sketch_id};
|
||||
use crate::params::{bad, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The stations a boundary carries by default (the patch's resolution along the loop).
|
||||
const DEFAULT_SAMPLES: usize = 25;
|
||||
const DEFAULT_SAMPLES: usize = solidcraft_doc::BOUNDARY_SAMPLES;
|
||||
/// 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;
|
||||
/// The most curves one direction of a network may list (the kernel's own cap).
|
||||
const MAX_NETWORK_CURVES: usize = solidcraft_geom::network::MAX_NETWORK_CURVES;
|
||||
/// The most sketch curves one network curve may chain, and points picking one chain of edges
|
||||
/// (the kernel's own caps).
|
||||
use solidcraft_kernel::{MAX_CHAIN_CURVES as MAX_CHAIN, MAX_EDGE_PICKS as MAX_PICKS};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("surface.boundary", "Boundary Surface", boundary)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_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)",
|
||||
"Either a loop of one sketch's curves — 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; two: the patch spans between them and leaves each boundary along the tangent across it; three or four: the flat patch of the loop, three collapsing the side opposite the first curve to the corner there); 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; two curves only); samples?: 8…512 stations per boundary (default 25) — or a curve network — u: [curve] (two or more one way: the surface lofts through them), v?: [curve] (crossing every u-curve once: two each way give a Coons patch, more a Gordon surface through every curve; a curve that misses another by more than 0.01 mm is refused, naming the pair), each curve \"id\" (a curve of `sketch`) | {sketch?: id|name, curves: [ids meeting end to end]} (planar or 3D sketch curves) | {body: name, edges: [[x,y,z]] (points on a chain of its edges), continuity?: g0|g1|g2 (default g0; g1 the surface leaves tangent to the face beside a surface's open edges, g2 with its curvature too; on curves of one direction only)} | {point: [x,y,z]} (a side collapsed to a point: a three-sided patch); operation?: new|join (default new; join sews the surface into the body of its first edge curve, a solid when it closes the gap). name?, body_name? (a surface body)",
|
||||
)];
|
||||
|
||||
/// `surface.boundary`: one patch through a network of two to four sketch curves.
|
||||
/// `surface.boundary`: a loop of sketch curves, or a network when `u` is given.
|
||||
fn boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("u").is_some_and(|v| !v.is_null()) {
|
||||
return network(s, p);
|
||||
}
|
||||
loop_patch(s, p)
|
||||
}
|
||||
|
||||
/// `surface.boundary` with `curves`: one patch through two to four sketch curves in loop order.
|
||||
fn loop_patch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.boundary";
|
||||
if p.get("v").is_some_and(|v| !v.is_null()) || p.get("operation").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad(cmd, "`v` and `operation` belong to a curve network: give its curves as `u` and `v`"));
|
||||
}
|
||||
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"));
|
||||
return Err(bad(cmd, "`curves` must list 2 to 4 sketch curves in loop order (or give a network as `u` and `v`)"));
|
||||
}
|
||||
// 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.
|
||||
@@ -98,9 +116,134 @@ fn boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad(cmd, format!("`samples` must be a whole number of stations between {MIN_SAMPLES} and {MAX_SAMPLES}")))?
|
||||
}
|
||||
};
|
||||
if crown != 0.0 && curves.len() >= 3 {
|
||||
return Err(bad(cmd, "not supported yet: `crown` on a patch through three or four curves (it is the flat patch of its sides)"));
|
||||
}
|
||||
add_feature(s, p, FeatureKind::SurfaceBoundary { sketch: SketchId(sketch), curves, orders, crown, samples })
|
||||
}
|
||||
|
||||
/// `surface.boundary` with `u` (and `v`): one surface through a curve network.
|
||||
fn network(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.boundary";
|
||||
for key in ["curves", "continuity", "crown", "samples"] {
|
||||
if p.get(key).is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad(cmd, format!("`{key}` belongs to a loop of sketch curves: a network gives each curve its own continuity")));
|
||||
}
|
||||
}
|
||||
let operation = super::features::operation(p, cmd)?;
|
||||
if !matches!(operation, Operation::NewBody | Operation::Join) {
|
||||
return Err(bad(cmd, "`operation` must be new or join"));
|
||||
}
|
||||
// The sketch a bare curve id belongs to, found only when one is used.
|
||||
let mut default_sketch: Option<u64> = None;
|
||||
let u = network_curves(s, p, "u", &mut default_sketch)?;
|
||||
let v = network_curves(s, p, "v", &mut default_sketch)?;
|
||||
if u.is_empty() {
|
||||
return Err(bad(cmd, "`u` must list the curves of one direction"));
|
||||
}
|
||||
if u.len() + v.len() < 2 {
|
||||
return Err(bad(cmd, "a boundary surface needs two curves in one direction, or crossing curves"));
|
||||
}
|
||||
if operation == Operation::Join && !u.iter().chain(&v).any(|c| c.body.is_some()) {
|
||||
return Err(bad(cmd, "`operation` join needs a curve on a body's edges: the surface is sewn into that body"));
|
||||
}
|
||||
add_feature(s, p, FeatureKind::SurfaceNetwork { u, v, operation })
|
||||
}
|
||||
|
||||
/// One direction of a network (`key` is `u` or `v`), checked: each curve sketch curves that exist
|
||||
/// (planar or 3D), a body's edges with points on them, or a point; a continuity only on edges.
|
||||
fn network_curves(s: &Session, p: &Value, key: &str, default_sketch: &mut Option<u64>) -> Result<Vec<NetworkCurve>> {
|
||||
let cmd = "surface.boundary";
|
||||
let list = match p.get(key) {
|
||||
None | Some(Value::Null) => return Ok(Vec::new()),
|
||||
Some(Value::Array(a)) => a,
|
||||
Some(_) => return Err(bad(cmd, format!("`{key}` must list curves"))),
|
||||
};
|
||||
if list.len() > MAX_NETWORK_CURVES {
|
||||
return Err(bad(cmd, format!("`{key}` may list at most {MAX_NETWORK_CURVES} curves")));
|
||||
}
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
for (k, item) in list.iter().enumerate() {
|
||||
let name = format!("{key}[{k}]");
|
||||
let mut curve = NetworkCurve { sketch: None, curves: Vec::new(), body: None, edges: Vec::new(), point: None, continuity: Continuity::G0 };
|
||||
let obj = match item {
|
||||
Value::String(id) => {
|
||||
curve.curves = vec![id.clone()];
|
||||
None
|
||||
}
|
||||
Value::Object(_) => Some(item),
|
||||
_ => return Err(bad(cmd, format!("{name} must be a curve id or {{sketch?, curves}}, {{body, edges}} or {{point}}"))),
|
||||
};
|
||||
if let Some(o) = obj {
|
||||
let has = |k: &str| o.get(k).is_some_and(|v| !v.is_null());
|
||||
let kinds = [has("curves"), has("body") || has("edges"), has("point")];
|
||||
if kinds.iter().filter(|x| **x).count() != 1 {
|
||||
return Err(bad(cmd, format!("{name} must be one of {{sketch?, curves}}, {{body, edges}} or {{point}}")));
|
||||
}
|
||||
if has("point") {
|
||||
curve.point = Some(Position(o.get("point").and_then(vec3).ok_or_else(|| bad(cmd, format!("{name}.point must be [x, y, z]")))?));
|
||||
} else if kinds[1] {
|
||||
let body = str_(o, "body").ok_or_else(|| bad(cmd, format!("{name}.body must name a body")))?;
|
||||
if s.model.state().body(body).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{body}`")));
|
||||
}
|
||||
let edges =
|
||||
o.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("{name}.edges must list points on the edges")))?;
|
||||
if edges.is_empty() || edges.len() > MAX_PICKS {
|
||||
return Err(bad(cmd, format!("{name}.edges must list 1 to {MAX_PICKS} points on a chain of edges")));
|
||||
}
|
||||
let pts: Vec<Position> = edges
|
||||
.iter()
|
||||
.map(|e| vec3(e).map(Position).ok_or_else(|| bad(cmd, format!("{name}.edges: a point must be [x, y, z]"))))
|
||||
.collect::<Result<_>>()?;
|
||||
curve.body = Some(BodyName::from(body.to_string()));
|
||||
curve.edges = pts;
|
||||
} else {
|
||||
curve.curves = string_list(o, "curves");
|
||||
if o.get("sketch").is_some_and(|v| !v.is_null()) {
|
||||
curve.sketch = Some(SketchId(sketch_id(s, o.get("sketch"), cmd, &format!("{name}.sketch"))?));
|
||||
}
|
||||
}
|
||||
if let Some(g) = o.get("continuity").filter(|v| !v.is_null()) {
|
||||
let g = g.as_str().and_then(Continuity::parse).ok_or_else(|| bad(cmd, format!("{name}.continuity must be g0, g1 or g2")))?;
|
||||
if g == Continuity::G3 {
|
||||
return Err(bad(cmd, format!("not supported yet: G3 on {name} (a curve network holds up to g2)")));
|
||||
}
|
||||
if g != Continuity::G0 && curve.body.is_none() {
|
||||
return Err(bad(cmd, format!("{name}: g1 and g2 apply to a curve on a body's edges (a surface's open edges)")));
|
||||
}
|
||||
curve.continuity = g;
|
||||
}
|
||||
}
|
||||
if curve.point.is_none() && curve.body.is_none() {
|
||||
if curve.curves.is_empty() || curve.curves.len() > MAX_CHAIN {
|
||||
return Err(bad(cmd, format!("{name} must list 1 to {MAX_CHAIN} sketch curves")));
|
||||
}
|
||||
let sketch = match curve.sketch {
|
||||
Some(id) => id.0,
|
||||
None => match *default_sketch {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let id = feature_sketch(s, p, cmd)?;
|
||||
*default_sketch = Some(id);
|
||||
id
|
||||
}
|
||||
},
|
||||
};
|
||||
let state = s.model.state();
|
||||
let sk = state.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch has not been solved"))?;
|
||||
for id in &curve.curves {
|
||||
if sk.sketch.curve_index(id).is_none() && sk.sketch.wire_index(id).is_none() {
|
||||
return Err(bad(cmd, format!("{name}: no sketch curve `{id}`")));
|
||||
}
|
||||
}
|
||||
curve.sketch = Some(SketchId(sketch));
|
||||
}
|
||||
out.push(curve);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{Value, json};
|
||||
@@ -154,27 +297,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boundary_patch_refuses_a_loop_and_values_it_cannot_hold() {
|
||||
fn a_boundary_patch_refuses_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;
|
||||
// A loop of three: the crown is the bridge's, not the flat loop patch's.
|
||||
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}");
|
||||
let e = s.execute("surface.boundary", &json!({"curves": [a, b, c], "crown": 1})).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet") && e.contains("crown"), "three boundaries with a crown: {e}");
|
||||
// Curves out of loop order.
|
||||
let e = s.execute("surface.boundary", &json!({"curves": [curves[0], curves[1], extra]})).unwrap_err().to_string();
|
||||
assert!(e.contains("loop order"), "{e}");
|
||||
// Parameters and curves that make no sense.
|
||||
let cases: Vec<(Value, &str)> = vec![
|
||||
(json!({"curves": [curves[0]]}), "2 to 4"),
|
||||
@@ -186,6 +321,7 @@ mod tests {
|
||||
(json!({"curves": curves, "crown": "full"}), "crown"),
|
||||
(json!({"curves": curves, "samples": 3}), "samples"),
|
||||
(json!({"curves": curves, "samples": 2.5}), "samples"),
|
||||
(json!({"curves": curves, "operation": "join"}), "network"),
|
||||
];
|
||||
for (params, want) in cases {
|
||||
let e = s.execute("surface.boundary", ¶ms).unwrap_err().to_string();
|
||||
@@ -195,3 +331,7 @@ mod tests {
|
||||
assert!(s.model.state().bodies.is_empty(), "a refused patch left a body behind");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "surface_boundary_tests.rs"]
|
||||
mod network_tests;
|
||||
@@ -0,0 +1,267 @@
|
||||
//! `surface.boundary` through loops of three or four sketch curves and through curve networks
|
||||
//! (#763): analytic areas and shapes, G1 and G2 measured across the edge a surface shares with a
|
||||
//! body (`inspect.continuity`), and refusals of hostile networks that never panic.
|
||||
|
||||
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 first(v: &Value) -> String {
|
||||
v.as_array().and_then(|a| a.first()).and_then(Value::as_str).map(str::to_string).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A sketch line's id.
|
||||
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String {
|
||||
first(&run(s, "sketch.line", json!({"points": [a, b]}))["created"]["curves"])
|
||||
}
|
||||
|
||||
/// A 3D sketch line's id (the active sketch must have 3D Sketch on).
|
||||
fn line3d(s: &mut Session, a: [f64; 3], b: [f64; 3]) -> String {
|
||||
first(&run(s, "sketch.line3d", json!({"points": [a, b]}))["curves"])
|
||||
}
|
||||
|
||||
/// The area of the one face of `body`, and the area `inspect.measure` gives for the whole model.
|
||||
fn areas(s: &mut Session, body: &str) -> (f64, f64) {
|
||||
let f = run(s, "model.faces", json!({"body": body}));
|
||||
let faces = f["faces"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(faces.len(), 1, "one face: {f}");
|
||||
let face = faces[0]["area"].as_f64().unwrap_or(f64::NAN);
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
(face, m["total"]["area_mm2"].as_f64().unwrap_or(f64::NAN))
|
||||
}
|
||||
|
||||
/// Three and four sketch curves in loop order: the flat patch of the loop. A triangle and a square
|
||||
/// of lines, and a square whose top is a half circle bulging out, each measure their own area.
|
||||
#[test]
|
||||
fn three_and_four_curve_loops_are_flat_patches() {
|
||||
use std::f64::consts::PI;
|
||||
let cases: Vec<(Vec<[[f64; 2]; 2]>, f64, f64)> = vec![
|
||||
(vec![[[0.0, 0.0], [20.0, 0.0]], [[20.0, 0.0], [10.0, 15.0]], [[10.0, 15.0], [0.0, 0.0]]], 150.0, 1e-6),
|
||||
(vec![[[0.0, 0.0], [10.0, 0.0]], [[10.0, 0.0], [10.0, 10.0]], [[10.0, 10.0], [0.0, 10.0]], [[0.0, 10.0], [0.0, 0.0]]], 100.0, 1e-6),
|
||||
];
|
||||
for (lines, want, tol) in cases {
|
||||
for continuity in [json!("g0"), json!("g1"), json!("g3")] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let curves: Vec<String> = lines.iter().map(|[a, b]| line(&mut s, *a, *b)).collect();
|
||||
let r = run(&mut s, "surface.boundary", json!({"curves": curves, "continuity": continuity}));
|
||||
let body = first(&r["created_bodies"]);
|
||||
let (face, total) = areas(&mut s, &body);
|
||||
assert!(
|
||||
(face - want).abs() < tol * want && (total - want).abs() < tol * want,
|
||||
"{} sides, {continuity}: {face} / {total} vs {want}",
|
||||
lines.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
// A square with a half circle for its top, bulging out: 100 + 12.5π.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let bottom = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let right = line(&mut s, [10.0, 0.0], [10.0, 10.0]);
|
||||
let arc = first(&run(&mut s, "sketch.arc.three_point", json!({"start": [10, 10], "end": [0, 10], "through": [5, 15]}))["created"]["curves"]);
|
||||
let left = line(&mut s, [0.0, 10.0], [0.0, 0.0]);
|
||||
let r = run(&mut s, "surface.boundary", json!({"curves": [bottom, right, arc, left]}));
|
||||
let (face, total) = areas(&mut s, &first(&r["created_bodies"]));
|
||||
let want = 100.0 + 12.5 * PI;
|
||||
assert!((face - want).abs() < 2e-3 * want && (total - want).abs() < 2e-3 * want, "{face} / {total} vs {want}");
|
||||
}
|
||||
|
||||
/// Simpson's rule over [0, a] × [0, a] with n (even) intervals each way.
|
||||
fn simpson2(f: impl Fn(f64, f64) -> f64, a: f64, n: usize) -> f64 {
|
||||
let h = a / n as f64;
|
||||
let w = |k: usize| {
|
||||
if k == 0 || k == n {
|
||||
1.0
|
||||
} else if k % 2 == 1 {
|
||||
4.0
|
||||
} else {
|
||||
2.0
|
||||
}
|
||||
};
|
||||
let mut sum = 0.0;
|
||||
for i in 0..=n {
|
||||
for j in 0..=n {
|
||||
sum += w(i) * w(j) * f(i as f64 * h, j as f64 * h);
|
||||
}
|
||||
}
|
||||
sum * h * h / 9.0
|
||||
}
|
||||
|
||||
/// Four straight 3D sketch lines bounding the hyperbolic paraboloid z = x·y/10 over the 10 × 10
|
||||
/// square: the network's Coons patch is that surface (every vertex of its mesh on it), and its
|
||||
/// area is the paraboloid's, ∫∫ √(1 + (x/10)² + (y/10)²).
|
||||
#[test]
|
||||
fn a_network_of_3d_lines_is_a_hyperbolic_paraboloid() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d": true}));
|
||||
let a = line3d(&mut s, [0.0, 0.0, 0.0], [10.0, 0.0, 0.0]);
|
||||
let c = line3d(&mut s, [0.0, 10.0, 0.0], [10.0, 10.0, 10.0]);
|
||||
let d = line3d(&mut s, [0.0, 0.0, 0.0], [0.0, 10.0, 0.0]);
|
||||
let b = line3d(&mut s, [10.0, 0.0, 0.0], [10.0, 10.0, 10.0]);
|
||||
let r = run(&mut s, "surface.boundary", json!({"u": [a, c], "v": [d, b]}));
|
||||
let body = first(&r["created_bodies"]);
|
||||
let want = simpson2(|x, y| (1.0 + (x / 10.0).powi(2) + (y / 10.0).powi(2)).sqrt(), 10.0, 200);
|
||||
let (face, total) = areas(&mut s, &body);
|
||||
// The face's own area comes from the coarse reference mesh (a chord of a fiftieth of a per
|
||||
// cent of the body); the measuring mesh is refined onto the surface.
|
||||
assert!((face - want).abs() < 1e-2 * want && (total - want).abs() < 1e-3 * want, "{face} / {total} vs {want}");
|
||||
let st = s.world_state();
|
||||
let mesh = st.body(&body).map(|b| b.body.tessellate(0.01)).and_then(Result::ok).unwrap_or_default();
|
||||
assert!(!mesh.positions.is_empty());
|
||||
for p in &mesh.positions {
|
||||
assert!((p.z - p.x * p.y / 10.0).abs() < 1e-6, "{p:?} is off the paraboloid");
|
||||
}
|
||||
}
|
||||
|
||||
/// Curves of two planar sketches, one picked by a bare id in the sketch being edited: the loft
|
||||
/// between a line on the ground and one 10 mm across and 5 mm up is the flat strip between them.
|
||||
#[test]
|
||||
fn a_network_takes_curves_from_several_sketches() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Low"}));
|
||||
let low = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 5, "name": "High"}));
|
||||
let high = line(&mut s, [0.0, 10.0], [10.0, 10.0]);
|
||||
let r = run(&mut s, "surface.boundary", json!({"u": [{"sketch": "Low", "curves": [low]}, high]}));
|
||||
let (face, _) = areas(&mut s, &first(&r["created_bodies"]));
|
||||
let want = 10.0 * 125f64.sqrt();
|
||||
assert!((face - want).abs() < 1e-6 * want, "{face} vs {want}");
|
||||
}
|
||||
|
||||
/// A quarter cylinder sheet (R = 10, axis along x, its straight top edge at y = 10, z = 0 open)
|
||||
/// and a line 3D-sketched above it; returns the sheet's name and the line's id.
|
||||
fn cylinder_and_line(s: &mut Session) -> (String, String) {
|
||||
run(s, "sketch.create", json!({"plane": "YZ", "name": "Arc"}));
|
||||
let h = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
|
||||
let arc = first(&run(s, "sketch.arc.three_point", json!({"start": [0, -10], "end": [10, 0], "through": [h, -h]}))["created"]["curves"]);
|
||||
run(s, "sketch.finish", json!({}));
|
||||
let sheet = first(&run(s, "surface.extrude", json!({"sketch": "Arc", "curves": [arc], "distance": 10}))["created_bodies"]);
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Top"}));
|
||||
run(s, "sketch.options", json!({"sketch_3d": true}));
|
||||
let top = line3d(s, [0.0, 3.0, 12.0], [10.0, 3.0, 12.0]);
|
||||
run(s, "sketch.finish", json!({}));
|
||||
(sheet, top)
|
||||
}
|
||||
|
||||
/// The edge a surface built from the cylinder's top edge shares with it, measured with
|
||||
/// `inspect.continuity`: (normal angle in degrees, curvature jump per mm).
|
||||
fn seam(s: &mut Session, body: &str) -> (f64, f64) {
|
||||
let v = run(s, "inspect.continuity", json!({"body": body, "edges": [[5.0, 10.0, 0.0]]}));
|
||||
let e = v["bodies"][0]["edges"].as_array().and_then(|a| a.first()).cloned().unwrap_or_default();
|
||||
(e["normal_angle_deg"]["value"].as_f64().unwrap_or(f64::NAN), e["curvature_jump_per_mm"]["value"].as_f64().unwrap_or(f64::NAN))
|
||||
}
|
||||
|
||||
/// G1 and G2 against a body: a network from the cylinder's open top edge to a line above, joined
|
||||
/// into the cylinder. Across the shared edge the normals agree within 0.1° for G1 and G2; the
|
||||
/// curvature jumps by much of the cylinder's 1/R with G1 and by under 1% of it with G2.
|
||||
#[test]
|
||||
fn g1_and_g2_networks_meet_a_body_across_its_edge() {
|
||||
let mut jumps = Vec::new();
|
||||
for g in ["g1", "g2"] {
|
||||
let mut s = Session::default();
|
||||
let (sheet, top) = cylinder_and_line(&mut s);
|
||||
let r = run(
|
||||
&mut s,
|
||||
"surface.boundary",
|
||||
json!({"u": [{"body": sheet, "edges": [[5.0, 10.0, 0.0]], "continuity": g}, {"sketch": "Top", "curves": [top]}], "operation": "join"}),
|
||||
);
|
||||
assert!(r["created_bodies"].as_array().is_none_or(Vec::is_empty), "a join makes no new body: {r}");
|
||||
let f = run(&mut s, "model.faces", json!({"body": sheet}));
|
||||
assert_eq!(f["faces"].as_array().map(Vec::len), Some(2), "the surface is sewn into the sheet: {f}");
|
||||
let (angle, jump) = seam(&mut s, &sheet);
|
||||
assert!(angle < 0.1, "{g}: normals {angle}° apart across the edge");
|
||||
jumps.push(jump);
|
||||
}
|
||||
let r = 10.0;
|
||||
assert!(jumps[0] > 0.2 / r, "G1 leaves the curvature behind: jump {}", jumps[0]);
|
||||
assert!(jumps[1] < 0.01 / r, "G2 carries the cylinder's curvature across: jump {}", jumps[1]);
|
||||
}
|
||||
|
||||
/// Networks that cannot be built are refused with a reason — by the command's own checks or by the
|
||||
/// kernel — and leave nothing behind; none of them panics.
|
||||
#[test]
|
||||
fn hostile_networks_are_refused() {
|
||||
let mut s = Session::default();
|
||||
let (sheet, top) = cylinder_and_line(&mut s);
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 40, 0]}));
|
||||
let boxed = s.world_state().bodies.last().map(|b| b.name.clone()).unwrap_or_default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Flat"}));
|
||||
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let b = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
|
||||
let far = line(&mut s, [0.0, 20.0], [10.0, 20.0]);
|
||||
let ring = first(&run(&mut s, "sketch.circle.center", json!({"center": [50, 50], "radius": 3}))["created"]["curves"]);
|
||||
let bodies = s.world_state().bodies.len();
|
||||
let edge = |g: &str| json!({"body": sheet, "edges": [[5.0, 10.0, 0.0]], "continuity": g});
|
||||
let many: Vec<Value> = (0..33).map(|_| json!(a)).collect();
|
||||
let cases: Vec<(Value, &str)> = vec![
|
||||
(json!({"u": "l1"}), "list"),
|
||||
(json!({"u": [42, a]}), "curve id"),
|
||||
(json!({"u": [{}, a]}), "one of"),
|
||||
(json!({"u": [{"curves": [], "sketch": "Flat"}, a]}), "sketch curves"),
|
||||
(json!({"u": [{"curves": ["nope"]}, a]}), "no sketch curve"),
|
||||
(json!({"u": [{"curves": [a], "sketch": "Nope"}, b]}), "no sketch"),
|
||||
(json!({"u": [{"body": "Nope", "edges": [[0, 0, 0]]}, a]}), "no body"),
|
||||
(json!({"u": [{"body": sheet, "edges": []}, a]}), "edges"),
|
||||
(json!({"u": [{"body": sheet, "edges": [["x", 0, 0]]}, a]}), "[x, y, z]"),
|
||||
(json!({"u": [{"body": sheet, "edges": [[1e300, 0, 0]]}, a]}), "[x, y, z]"),
|
||||
(json!({"u": [{"point": [0, 0]}, a]}), "point"),
|
||||
(json!({"u": [{"curves": [a], "body": sheet, "edges": [[5, 10, 0]]}, b]}), "one of"),
|
||||
(json!({"u": [{"curves": [a], "continuity": "g1"}, b]}), "body's edges"),
|
||||
(json!({"u": [edge("g3"), {"sketch": "Top", "curves": [top]}]}), "not supported yet"),
|
||||
(json!({"u": [edge("g9"), {"sketch": "Top", "curves": [top]}]}), "continuity"),
|
||||
(json!({"u": many}), "at most"),
|
||||
(json!({"u": [a, b], "curves": [a, b]}), "loop"),
|
||||
(json!({"u": [a, b], "crown": 1}), "loop"),
|
||||
(json!({"u": [a, b], "operation": "cut"}), "operation"),
|
||||
(json!({"u": [a, b], "operation": "join"}), "join"),
|
||||
(json!({"u": [a]}), "two curves"),
|
||||
(json!({"u": [], "v": [a, b]}), "`u`"),
|
||||
// The kernel's refusals: curves that miss, a closed curve, G1 on an edge between two faces.
|
||||
(json!({"u": [a, b], "v": [{"curves": [far]}, {"curves": [a]}]}), "don't meet"),
|
||||
(json!({"u": [{"curves": [ring]}, b]}), "closed"),
|
||||
(json!({"u": [{"body": boxed, "edges": [[45, 40, 0]], "continuity": "g1"}, b]}), "open edge"),
|
||||
(json!({"u": [a, {"point": [5, 2, 0]}, b]}), "point"),
|
||||
];
|
||||
for (params, want) in cases {
|
||||
let e = match s.execute("surface.boundary", ¶ms) {
|
||||
Ok(v) => panic!("{params} was built: {v}"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(!matches!(e, crate::EngineError::Internal(..)), "{params}: {e}");
|
||||
assert!(e.to_string().contains(want), "{params}: {e}");
|
||||
}
|
||||
assert_eq!(s.world_state().bodies.len(), bodies, "a refused network left a body behind");
|
||||
}
|
||||
|
||||
/// An edit (`timeline.redefine`) may turn a loop of sketch curves into the network of the same
|
||||
/// curves and back: the feature keeps its place and its name, and the surface its area.
|
||||
#[test]
|
||||
fn an_edit_turns_a_loop_into_a_network_and_back() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Square"}));
|
||||
let sides: Vec<String> = [[[0.0, 0.0], [10.0, 0.0]], [[10.0, 0.0], [10.0, 10.0]], [[10.0, 10.0], [0.0, 10.0]], [[0.0, 10.0], [0.0, 0.0]]]
|
||||
.iter()
|
||||
.map(|[a, b]| line(&mut s, *a, *b))
|
||||
.collect();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "surface.boundary", json!({"sketch": "Square", "curves": sides, "name": "Patch"}));
|
||||
let network = json!({"sketch": "Square", "u": [sides[0], sides[2]], "v": [sides[1], sides[3]]});
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Patch", "command": "surface.boundary", "params": network}));
|
||||
let kind = |s: &Session| s.doc.find_feature("Patch").map(|f| f.kind.clone());
|
||||
assert!(matches!(kind(&s), Some(solidcraft_doc::FeatureKind::SurfaceNetwork { .. })), "{:?}", kind(&s));
|
||||
let body = s.world_state().bodies.first().map(|b| b.name.clone()).unwrap_or_default();
|
||||
assert!((areas(&mut s, &body).0 - 100.0).abs() < 1e-6);
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Patch", "command": "surface.boundary", "params": {"sketch": "Square", "curves": sides}}));
|
||||
assert!(matches!(kind(&s), Some(solidcraft_doc::FeatureKind::SurfaceBoundary { .. })), "{:?}", kind(&s));
|
||||
assert!((areas(&mut s, &body).0 - 100.0).abs() < 1e-6);
|
||||
}
|
||||
Reference in new issue
Block a user