engine: mesh.node.move moves nodes by a vector or to a point

The grabbed nodes (listed, on B-rep faces, or a sphere's) move; the nodes
within the radius follow by a falloff or harmonically; nodes stay on the
geometry as Relax Nodes moves them; the move is scaled back until no element
inverts. The reply gives the region's scaled Jacobian before and after and the
elements that got worse and better. One undo step. Toolbar entry Move Nodes
(Mesh, Improve) with its own icon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-17 10:35:53 -04:00
1 parent e52eda728f
commit 711e51074c
11 files changed
+586 -10

No files matched your search

+1
View File
@@ -96,6 +96,7 @@ is committed and listed here with its exports.
| assets/icons/mesh.node.add.svg, assets/icons/mesh.node.add.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.add.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.node.merge.svg, assets/icons/mesh.node.merge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.merge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.node.relax.svg, assets/icons/mesh.node.relax.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.relax.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.node.move.svg, assets/icons/mesh.node.move.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.move.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.order.quadratic.svg, assets/icons/mesh.order.quadratic.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.order.quadratic.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.quad.faces.svg, assets/icons/mesh.quad.faces.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quad.faces.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.quality.svg, assets/icons/mesh.quality.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>mesh.node.move</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M4 4 L20 4 L20 20 L4 20 Z" fill="none" stroke="#dde2ea" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 4 L12 20 M4 12 L20 12" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.5 1.5"/>
<path d="M12 4 L16 8.5 M20 12 L16 8.5 M4 12 L16 8.5 M12 20 L16 8.5" fill="none" stroke="#dde2ea" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.7 12 C10.7 11.282 11.282 10.7 12 10.7 C12.718 10.7 13.3 11.282 13.3 12 C13.3 12.718 12.718 13.3 12 13.3 C11.282 13.3 10.7 12.718 10.7 12 Z" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.6 8.5 C13.6 7.175 14.675 6.1 16 6.1 C17.325 6.1 18.4 7.175 18.4 8.5 C18.4 9.825 17.325 10.9 16 10.9 C14.675 10.9 13.6 9.825 13.6 8.5 Z" fill="#4f9ce8" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"rect": [4, 4, 16, 16], "stroke": "line", "width": 1.25}, {"d": "M12 4 L12 20 M4 12 L20 12", "stroke": "line", "width": 1.0, "dash": [1.5, 1.5]}, {"d": "M12 4 L16 8.5 M20 12 L16 8.5 M4 12 L16 8.5 M12 20 L16 8.5", "stroke": "line", "width": 1.25}, {"circle": [12, 12, 1.3], "stroke": "line", "width": 1.0}, {"circle": [16, 8.5, 2.4], "fill": "accent", "stroke": "accent"}]}
+7
View File
@@ -437,6 +437,13 @@ pub static CATALOGUE: &[Capability] = &[
"Relax the nodes inside a sphere to fix spikes and slivers; surface nodes slide on the geometry; kept as a field to apply again after a remesh",
&["mesh.node.relax.apply"],
),
ready(
"mesh.node.move",
"Move Nodes",
Mesh,
"Improve",
"Drag nodes of the mesh, the nodes round them following by a falloff or harmonically, on the geometry; the original drawn as a ghost and the elements coloured by how their quality changes",
),
ready_more("mesh.node.add", "Node", Mesh, "Edit", "Add a node", &["mesh.node.remove"]),
ready_more("mesh.element.add", "Element", Mesh, "Edit", "Add an element on existing nodes", &["mesh.element.remove", "mesh.clear"]),
ready("mesh.mirror", "Mirror Mesh", Mesh, "Edit", "Mirror a body's mesh across a plane, joined on the plane (mesh half a symmetric part)"),
+27
View File
@@ -1499,6 +1499,33 @@ pub const NODE_RELAX: &[Field] = &[
flag("reapply", "Reapply after remesh").help("Apply the field again by itself after each remesh.").def("false"),
];
pub const NODE_MOVE: &[Field] = &[
labels("nodes", "Nodes", LabelOf::Node).help("The nodes to grab: drag a node in the view, or box-select nodes and Use selection.").hint("drag a node"),
face_rows("faces", "Faces").help("Instead of nodes: every node on these CAD faces.").hint("none"),
xyz("center", "Centre", D::Length).help("Instead of nodes: a sphere's centre; every node inside follows by the falloff.").hint("none"),
xyz("by", "Move by", D::Length).help("The displacement of the grabbed nodes; dragging in the view sets it."),
xyz("to", "Move to", D::Length).help("Instead of Move by: where the grabbed nodes' centroid (or the centre) goes.").hint("none"),
positive("radius", "Radius", D::Length)
.help("Nodes this close to a grabbed node follow it, blended to zero at the radius; Alt + scroll over the view changes it.")
.hint("only the grabbed nodes"),
choice("propagate", "Followers", &[("falloff", "Falloff (by distance)"), ("harmonic", "Harmonic (through the mesh)")])
.help("Falloff: a node follows by the falloff of its distance. Harmonic: the move spreads through the element edges as a membrane carries it, zero at the radius.")
.def("\"falloff\""),
choice("falloff", "Falloff", &[("smooth", "Smooth"), ("linear", "Linear"), ("none", "None")])
.help("Falloff followers: how the share falls from the grabbed nodes to the radius.")
.hint("smooth"),
range("strength", "Strength", D::None, 0.0, 1.0).help("Falloff followers: the largest share of the move a follower takes, 0 to 1.").hint("1"),
flag("on_geometry", "Stay on the geometry")
.help("Nodes on CAD faces slide on them, on CAD edges along them; nodes on CAD vertices stay.")
.def("true"),
choice("guard", "Guard", &[("no_inversion", "Scale back to no inverted element"), ("none", "None")])
.help("No inversion: the move is scaled back until no element inverts or falls below the smallest quality.")
.def("\"no_inversion\""),
range("min_quality", "Smallest quality", D::None, 0.0, 1.0)
.help("No inversion: the smallest scaled Jacobian the move may leave an element with (one already below keeps its own).")
.def("0.05"),
];
pub const NODE_RELAX_APPLY: &[Field] = &[
text("field", "Field").req().help("The relaxation field kept by Relax Nodes."),
references("bodies", "Bodies", C::Body).help("Only the nodes of these bodies.").hint("the field's bodies"),
+4
View File
@@ -18,12 +18,15 @@ pub(crate) mod mesh;
pub use datum::edit_params as datum_edit_params;
pub use datum::preview as datum_preview;
pub use fieldcraft_mesh::relax::{ElementChange, Moved as NodeMoved};
pub use mesh::analytic_surfaces;
pub use node_move::{Prepared as NodeMovePrepared, nearest_node as node_move_nearest, prepare as node_move_prepare};
pub use nodes::merge_preview as node_merge_preview;
pub use plan::untagged_faces;
pub use relax::{Preview as RelaxPreview, preview as relax_preview, sphere as relax_sphere};
mod mirror;
mod model;
pub(crate) mod node_move;
pub(crate) mod nodes;
pub(crate) mod octree;
pub(crate) mod plan;
@@ -121,6 +124,7 @@ pub static ALL: &[CommandSpec] = &[
nodes::RENUMBER,
relax::RELAX,
relax::APPLY,
node_move::MOVE,
tags::TAG,
tags::TAG_CLEAR,
model::UNITS,
+261
View File
@@ -0,0 +1,261 @@
//! Moving nodes by hand: `mesh.node.move` displaces grabbed nodes (listed, on listed B-rep faces,
//! or a sphere's) by a vector or to a point, the nodes round them following by a falloff or by
//! harmonic interpolation ([`fieldcraft_mesh::relax::move_weights`]), nodes on the geometry
//! staying on it as Relax Nodes moves them ([`super::relax`]), the move scaled back until no
//! element inverts; it reports what the move did to the elements' quality.
//!
//! The interactive tool (drag a node in the view) works out the same move live: [`prepare`] does
//! the part that does not depend on the displacement once (which nodes follow, and the design's
//! faces they slide on), and [`Prepared::preview`] applies a displacement to it.
use std::collections::{BTreeMap, BTreeSet};
use fieldcraft_mesh::relax::{self, Constraints, Falloff, Guard, Influence, Moved, Propagate, Unconstrained};
use serde_json::{Value, json};
use super::relax::{DesignGeometry, design_geometry, face_refs, nodes_on_faces, point, quality_json, unmeshed_faces};
use crate::requirement::Requirement;
use crate::schema::ProblemKind;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const MOVE: CommandSpec = CommandSpec {
id: "mesh.node.move",
label: "Move Nodes",
params: r#"{"nodes": [labels] (the nodes to grab) | "faces": [{"body": "Base", "face": "F3"}] (every node on these B-rep faces) | "center": [x, y, z] with "radius": mm (a sphere: every node inside follows by the falloff of its distance to the centre), "by": [dx, dy, dz] | "to": [x, y, z] (where the grabbed nodes' centroid, or the centre, goes), "radius"?: mm (with nodes or faces: the nodes this close to a grabbed one follow; default none, only the grabbed nodes move), "propagate"?: "falloff" (default) | "harmonic" (the displacement carried into the nodes within the radius by harmonic interpolation on the graph of element edges, 1 at the grabbed nodes and zero beyond the radius; needs a radius, and the grabbed nodes: a sphere grabs its nearest node), "falloff"?: "smooth" (default) | "linear" | "none", "strength"?: 1 (0..1]: the largest share of the displacement a node that is not grabbed takes (falloff only), "on_geometry"?: true (nodes on a B-rep face slide on it in its UV parameters, on a B-rep edge along it, on a vertex stay, and skin nodes of a mesh without geometry stay; false: every node moves in space), "guard"?: "no_inversion" (default: the move is scaled back until no element inverts or falls below `min_quality`) | "none", "min_quality"?: 0.05 (0..1, no_inversion only: the smallest scaled Jacobian the move may leave an element with; an element already below keeps at least its own)} — moves the nodes; mid-side nodes follow. Reports the nodes moved, the largest displacement, the displacement applied and its scale (below 1 when the guard scaled it back), the quality of the elements round the moved nodes before and after (smallest and mean scaled Jacobian, largest edge ratio, invalid elements, the worst) and how many got worse and better. One undo step; a move that moves nothing (a zero displacement, or one the guard scales back to nothing) changes nothing and records no undo step (`"moved": 0`, `"nothing_moved": true`)"#,
form: Some(super::forms::NODE_MOVE),
kind: CommandKind::Model,
requires: &[Requirement::Mesh],
run: move_nodes,
};
/// Where nodes may go: the design's faces and edges, or anywhere.
enum Geometry {
Design(DesignGeometry),
Free,
}
impl Geometry {
fn constraints(&self) -> &dyn Constraints {
match self {
Geometry::Design(g) => g,
Geometry::Free => &Unconstrained,
}
}
}
/// A move worked out up to its displacement: who follows and by how much, and where they may go.
pub struct Prepared {
/// Node → its share of the displacement.
weights: BTreeMap<u32, f64>,
/// The grabbed nodes.
handles: BTreeSet<u32>,
/// Where a move `to` a point starts from: the grabbed nodes' centroid, or the centre.
origin: Option<[f64; 3]>,
/// The sphere of influence round the centre or the grabbed nodes, for the view.
pub sphere: Option<([f64; 3], f64)>,
guard: Guard,
geometry: Geometry,
}
impl Prepared {
/// Nodes that take a share of the displacement.
pub fn nodes(&self) -> usize {
self.weights.len()
}
/// The grabbed nodes.
pub fn handles(&self) -> &BTreeSet<u32> {
&self.handles
}
/// The displacement `params` ask for (`by`, or `to` less the origin).
pub fn displacement(&self, params: &Value) -> Result<[f64; 3]> {
match (point(params, "by")?, point(params, "to")?) {
(Some(_), Some(_)) => Err(EngineError::Params("give `by` or `to`, not both".into())),
(Some(d), None) => Ok(d),
(None, Some(to)) => {
let o = self.origin.ok_or_else(|| EngineError::Params("no node to move `to` a point".into()))?;
Ok([to[0] - o[0], to[1] - o[1], to[2] - o[2]])
}
(None, None) => Err(EngineError::Params("say how far to move: give `by` [dx, dy, dz] or `to` [x, y, z]".into())),
}
}
/// The move by `d` on `s`'s mesh, without doing it.
pub fn preview(&self, s: &Session, d: [f64; 3]) -> Result<Moved> {
Ok(relax::move_nodes(&s.model, &self.weights, d, self.guard, self.geometry.constraints())?)
}
}
/// The corner node nearest `p`: the node a drag in the view grabs.
pub fn nearest_node(s: &Session, p: [f64; 3]) -> Option<u32> {
relax::nearest_corner(&s.model, p)
}
/// A positive number parameter, when given.
fn positive(p: &Value, key: &str) -> Result<Option<f64>> {
match p.get(key) {
None => Ok(None),
Some(_) => Some(super::optional_number(p, key, 0.0)?)
.filter(|r| *r > 0.0 && r.is_finite())
.ok_or_else(|| EngineError::param(key, ProblemKind::NotPositive))
.map(Some),
}
}
/// Everything of `mesh.node.move` but its displacement.
pub fn prepare(s: &Session, p: &Value) -> Result<Prepared> {
let model = &s.model;
let propagate = match super::opt::<String>(p, "propagate")? {
None => Propagate::Falloff,
Some(v) => Propagate::from_name(&v).ok_or_else(|| EngineError::param("propagate", ProblemKind::NotAChoice))?,
};
if propagate == Propagate::Harmonic && ["falloff", "strength"].iter().any(|k| p.get(*k).is_some()) {
return Err(EngineError::Params("`falloff` and `strength` go with \"propagate\": \"falloff\"".into()));
}
let falloff = match super::opt::<String>(p, "falloff")? {
None => Falloff::Smooth,
Some(f) => Falloff::from_name(&f).ok_or_else(|| EngineError::param("falloff", ProblemKind::NotAChoice))?,
};
let strength = super::optional_number(p, "strength", 1.0)?;
if !(strength > 0.0 && strength <= 1.0) {
return Err(EngineError::param("strength", ProblemKind::NotInRange { min: 0.0, max: 1.0 }));
}
let min_quality = super::optional_number(p, "min_quality", relax::DEFAULT_MIN_QUALITY)?;
if !(0.0..=1.0).contains(&min_quality) {
return Err(EngineError::param("min_quality", ProblemKind::NotInRange { min: 0.0, max: 1.0 }));
}
let guard = match super::opt::<String>(p, "guard")?.as_deref() {
None | Some("no_inversion") => Guard::NoInversion { min_quality },
Some("none") if p.get("min_quality").is_some() => {
return Err(EngineError::Params("`min_quality` goes with \"guard\": \"no_inversion\"".into()));
}
Some("none") => Guard::None,
Some(_) => return Err(EngineError::param("guard", ProblemKind::NotAChoice)),
};
let on_geometry = super::opt::<bool>(p, "on_geometry")?.unwrap_or(true);
let radius = positive(p, "radius")?;
let centre = point(p, "center")?;
let listed = ["nodes", "faces"].iter().filter(|k| p.get(**k).is_some()).count() + usize::from(centre.is_some());
if listed != 1 {
return Err(EngineError::Params("say which nodes to move: give one of `nodes`, `faces` or `center` (with `radius`)".into()));
}
let mut handles: BTreeSet<u32> = BTreeSet::new();
if p.get("nodes").is_some() {
for n in super::labels(p, "nodes", fieldcraft_model::MAX_NODES)? {
if model.node(n).is_none() {
return Err(EngineError::Params(format!("no node {n}")));
}
handles.insert(n);
}
}
if p.get("faces").is_some() {
handles.extend(nodes_on_faces(model, &face_refs(p)?)?);
}
let mut origin = relax::centroid(model, &handles);
if let Some(c) = centre {
if radius.is_none() {
return Err(EngineError::Params("a sphere needs both `center` and `radius`".into()));
}
origin = Some(c);
// A harmonic move needs something to carry: the sphere grabs the node nearest its centre.
if propagate == Propagate::Harmonic {
handles.extend(relax::nearest_corner(model, c));
}
}
let influence = Influence { centre: if propagate == Propagate::Falloff { centre } else { None }, radius, propagate, strength, falloff };
// The constraints of every node that might take a share: the grabbed ones and those within
// the radius (all of the mesh's corner nodes when the region is not known yet).
let geometry = if on_geometry {
let candidates =
relax::move_weights(model, &handles, &Influence { propagate: Propagate::Falloff, falloff: Falloff::None, ..influence }, &Unconstrained)?;
let mut nodes: BTreeSet<u32> = candidates.keys().copied().collect();
// Their elements' nodes too: the mid-side nodes that follow.
for e in model.elements().values() {
if e.nodes.iter().any(|n| candidates.contains_key(n)) {
nodes.extend(e.nodes.iter().copied());
}
}
Geometry::Design(design_geometry(s, &nodes)?)
} else {
Geometry::Free
};
let weights = relax::move_weights(model, &handles, &influence, geometry.constraints())?;
if weights.is_empty() {
return Err(EngineError::Params("no mesh nodes to move: the nodes given are no element's corners, or none lies in the sphere".into()));
}
let sphere = radius.and_then(|r| centre.or(origin).map(|c| (c, r)));
Ok(Prepared { weights, handles, origin, sphere, guard, geometry })
}
/// The quality of the region as JSON, with its mean scaled Jacobian.
fn quality(q: &relax::RegionQuality, mean: f64) -> Value {
let mut v = quality_json(q);
if let Some(o) = v.as_object_mut() {
o.insert("mean_scaled_jacobian".into(), json!(mean));
}
v
}
fn move_nodes(s: &mut Session, p: &Value) -> Result<Value> {
let prep = prepare(s, p)?;
let d = prep.displacement(p)?;
let r = prep.preview(s, d)?;
let asked = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if r.moved == 0 && asked > 0.0 && r.scale > 0.0 {
return Err(EngineError::Params(
"the grabbed nodes may not move: they are on B-rep vertices (or on the skin of a mesh without geometry); give \"on_geometry\": false to move them freely".into(),
));
}
if r.positions.is_empty() {
// Nothing to do: the model is unchanged, so no undo step is recorded.
let why = if asked == 0.0 {
"the displacement is zero"
} else {
"the guard scaled the move back to nothing (any move would invert an element or go below min_quality)"
};
return Ok(json!({
"moved": 0,
"nodes_changed": 0,
"nothing_moved": true,
"scale": r.scale,
"before": quality(&r.before, r.mean_before),
"summary": format!("nothing moved: {why}"),
}));
}
for (n, x) in &r.positions {
s.model.set_node(*n, *x)?;
}
let summary = format!(
"moved {} node{} (largest {:.3e}{}); scaled Jacobian min {:.3} to {:.3}, mean {:.3} to {:.3}; {} element{} worse, {} better",
r.moved,
if r.moved == 1 { "" } else { "s" },
r.max_displacement,
if r.scale < 1.0 { format!(", scaled to {:.3} so that no element inverts", r.scale) } else { String::new() },
r.before.min_quality,
r.after.min_quality,
r.mean_before,
r.mean_after,
r.worse,
if r.worse == 1 { "" } else { "s" },
r.better,
);
Ok(json!({
"moved": r.moved,
"nodes_changed": r.positions.len(),
"grabbed": prep.handles.len(),
"max_displacement": r.max_displacement,
"displacement": d.map(|x| x * r.scale),
"scale": r.scale,
"before": quality(&r.before, r.mean_before),
"after": quality(&r.after, r.mean_after),
"worse": r.worse,
"better": r.better,
"sphere": prep.sphere.map(|(c, r)| json!({"center": c, "radius": r})),
"faces_without_mesh": unmeshed_faces(&s.model, p),
"summary": summary,
}))
}
#[cfg(test)]
mod tests;
+256
View File
@@ -0,0 +1,256 @@
use super::*;
use fieldcraft_model::Model;
/// The plate (40 × 30 × 10) with its Ø10 hole round (15, 15).
const HOLE: ([f64; 2], f64) = ([15.0, 15.0], 5.0);
fn plate() -> Session {
let mut s = super::super::datum::tests::plate().0;
s.execute("mesh.hex.generate", &json!({"size": 2.0})).unwrap();
s
}
fn hole_radius(p: [f64; 3]) -> f64 {
((p[0] - HOLE.0[0]).powi(2) + (p[1] - HOLE.0[1]).powi(2)).sqrt()
}
fn dist(a: [f64; 3], b: [f64; 3]) -> f64 {
(0..3).map(|i| (a[i] - b[i]).powi(2)).sum::<f64>().sqrt()
}
/// The node nearest `p` among those `keep` accepts.
fn nearest(m: &Model, p: [f64; 3], keep: impl Fn([f64; 3]) -> bool) -> u32 {
let mut best = (f64::INFINITY, 0);
for (n, x) in m.nodes() {
if keep(x.xyz) && dist(x.xyz, p) < best.0 {
best = (dist(x.xyz, p), *n);
}
}
best.1
}
/// An interior node (off every face of the plate).
fn interior(m: &Model) -> u32 {
nearest(m, [30.0, 15.0, 5.0], |p| p[2] > 1e-6 && p[2] < 10.0 - 1e-6 && p[0] > 1e-6 && p[0] < 40.0 - 1e-6 && p[1] > 1e-6 && p[1] < 30.0 - 1e-6)
}
fn invalid(s: &mut Session) -> u64 {
s.execute("mesh.quality", &json!({})).unwrap()["invalid"]["count"].as_u64().unwrap_or(0)
}
/// `by` moves the grabbed node by the vector, the reply carries the quality before and after,
/// one undo step takes it back and redo does it again.
#[test]
fn by_moves_a_node_in_one_undo_step() {
let mut s = plate();
let start = s.model.clone();
let n = interior(&s.model);
let p0 = s.model.node(n).unwrap().xyz;
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.3, -0.2, 0.1]})).unwrap();
assert_eq!((r["moved"].as_u64(), r["scale"].as_f64(), r["grabbed"].as_u64()), (Some(1), Some(1.0), Some(1)), "{r}");
assert!(dist(s.model.node(n).unwrap().xyz, [p0[0] + 0.3, p0[1] - 0.2, p0[2] + 0.1]) < 1e-12);
assert!(r["before"]["min_scaled_jacobian"].as_f64().unwrap() > r["after"]["min_scaled_jacobian"].as_f64().unwrap(), "{r}");
assert!(r["worse"].as_u64().unwrap() > 0 && r["before"]["mean_scaled_jacobian"].is_number(), "{r}");
let moved = s.model.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.model, start);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.model, moved);
}
/// `to` puts the grabbed nodes' centroid (or the sphere's centre) at the point.
#[test]
fn to_puts_the_centroid_at_the_point() {
let mut s = plate();
let n = interior(&s.model);
let p0 = s.model.node(n).unwrap().xyz;
let to = [p0[0] + 0.25, p0[1], p0[2] - 0.25];
s.execute("mesh.node.move", &json!({"nodes": [n], "to": to})).unwrap();
assert!(dist(s.model.node(n).unwrap().xyz, to) < 1e-12);
let c = s.model.node(n).unwrap().xyz;
let r = s.execute("mesh.node.move", &json!({"center": c, "radius": 3.0, "to": [c[0], c[1] + 0.2, c[2]]})).unwrap();
assert!(r["moved"].as_u64().unwrap() > 1, "{r}");
assert!((s.model.node(n).unwrap().xyz[1] - c[1] - 0.2).abs() < 1e-9, "the node at the centre takes all of it");
}
/// Each falloff moves the nodes within the radius, none beyond, the grabbed node by all of it;
/// they move the followers differently.
#[test]
fn each_falloff_moves_only_within_the_radius() {
let mut followers = Vec::new();
for falloff in ["smooth", "linear", "none"] {
let mut s = plate();
let start = s.model.clone();
let n = interior(&s.model);
let p0 = start.node(n).unwrap().xyz;
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.0, 0.0, 0.2], "radius": 4.0, "falloff": falloff})).unwrap();
assert!(r["moved"].as_u64().unwrap() > 1, "{falloff}: {r}");
let mut sum = 0.0;
for (k, x) in s.model.nodes() {
let old = start.node(*k).unwrap().xyz;
if old != x.xyz {
assert!(dist(old, p0) < 4.0 + 1e-9, "{falloff}: node {k} beyond the radius moved");
sum += dist(old, x.xyz);
}
}
assert!((s.model.node(n).unwrap().xyz[2] - p0[2] - 0.2).abs() < 1e-12);
followers.push(sum);
}
assert!(followers[0] < followers[1] && followers[1] < followers[2], "{followers:?}");
}
/// Harmonic: the nodes within the radius follow, the move falls to nothing at the radius and no
/// node beyond it moves; falloff and strength do not go with it.
#[test]
fn harmonic_propagation_is_zero_outside_the_radius() {
let mut s = plate();
let start = s.model.clone();
let n = interior(&s.model);
let p0 = start.node(n).unwrap().xyz;
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.2, 0.0, 0.0], "radius": 5.0, "propagate": "harmonic"})).unwrap();
assert!(r["moved"].as_u64().unwrap() > 5, "{r}");
for (k, x) in s.model.nodes() {
let old = start.node(*k).unwrap().xyz;
if old != x.xyz {
let d = dist(old, p0);
assert!(d < 5.0 + 1e-9, "node {k} beyond the radius moved");
assert!(dist(old, x.xyz) <= 0.2 + 1e-12);
}
}
assert_eq!(invalid(&mut s), 0);
let e = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.1, 0.0, 0.0], "radius": 5.0, "propagate": "harmonic", "falloff": "linear"}));
assert!(e.is_err());
assert!(s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.1, 0.0, 0.0], "propagate": "harmonic"})).is_err(), "needs a radius");
}
/// On the geometry: a node on the hole slides round it (stays on the cylinder), its followers on
/// the caps stay in their planes; a node at a corner of the plate is held (an error says how to
/// move it), and moves with the geometry off.
#[test]
fn on_geometry_keeps_nodes_on_faces_and_edges_and_holds_vertices() {
let mut s = plate();
let start = s.model.clone();
let on_hole = |p: [f64; 3]| (hole_radius(p) - HOLE.1).abs() < 1e-6;
let n = nearest(&s.model, [20.0, 15.0, 5.0], |p| on_hole(p) && p[2] > 1e-6 && p[2] < 10.0 - 1e-6);
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.0, 0.6, 0.4], "radius": 3.0, "propagate": "harmonic"})).unwrap();
assert!(r["moved"].as_u64().unwrap() > 1, "{r}");
assert_ne!(s.model.node(n).unwrap().xyz, start.node(n).unwrap().xyz);
for (k, x) in s.model.nodes() {
let old = start.node(*k).unwrap().xyz;
if on_hole(old) {
assert!(on_hole(x.xyz), "node {k} left the hole: r = {}", hole_radius(x.xyz));
}
for z in [0.0, 10.0] {
if (old[2] - z).abs() < 1e-9 {
assert!((x.xyz[2] - z).abs() < 1e-9, "node {k} left its cap");
}
}
}
// A node on the hole's rim (the edge of the hole and a cap) slides along the rim.
let rim = nearest(&start, [20.0, 15.0, 10.0], |p| on_hole(p) && (p[2] - 10.0).abs() < 1e-9);
let mut s2 = plate();
s2.execute("mesh.node.move", &json!({"nodes": [rim], "by": [0.0, 0.8, 0.5]})).unwrap();
let x = s2.model.node(rim).unwrap().xyz;
assert!(on_hole(x) && (x[2] - 10.0).abs() < 1e-9 && x != start.node(rim).unwrap().xyz, "{x:?}");
// The plate's corner is a vertex.
let corner = nearest(&start, [0.0, 0.0, 0.0], |_| true);
let e = s2.execute("mesh.node.move", &json!({"nodes": [corner], "by": [0.3, 0.3, 0.3]})).unwrap_err();
assert!(e.to_string().contains("on_geometry"), "{e}");
s2.execute("mesh.node.move", &json!({"nodes": [corner], "by": [0.3, 0.3, 0.3], "on_geometry": false})).unwrap();
assert!(dist(s2.model.node(corner).unwrap().xyz, [0.3, 0.3, 0.3]) < 1e-9);
}
/// The guard scales a move that would invert elements back so that none does; without it they
/// invert.
#[test]
fn the_guard_scales_back_to_no_inversion() {
let mut s = plate();
let n = interior(&s.model);
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [3.0, 0.0, 0.0]})).unwrap();
let scale = r["scale"].as_f64().unwrap();
assert!(scale > 0.0 && scale < 1.0, "{r}");
assert_eq!(r["after"]["invalid"], 0, "{r}");
assert_eq!(invalid(&mut s), 0);
let mut s = plate();
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [3.0, 0.0, 0.0], "guard": "none"})).unwrap();
assert_eq!(r["scale"], 1.0);
assert!(r["after"]["invalid"].as_u64().unwrap() > 0, "{r}");
}
/// The preview of the interactive tool is the command's move, without doing it.
#[test]
fn the_preview_is_the_move_without_doing_it() {
let mut s = plate();
let n = interior(&s.model);
let p = json!({"nodes": [n], "by": [0.2, 0.1, 0.0], "radius": 4.0});
let prep = prepare(&s, &p).unwrap();
assert!(prep.nodes() > 1 && prep.handles().contains(&n) && prep.sphere.is_some());
let start = s.model.clone();
let preview = prep.preview(&s, prep.displacement(&p).unwrap()).unwrap();
assert_eq!(s.model, start);
s.execute("mesh.node.move", &p).unwrap();
for (k, x) in &preview.positions {
assert_eq!(s.model.node(*k).unwrap().xyz, *x);
}
assert!(preview.elements.iter().any(|e| e.after != e.before));
}
#[test]
fn hostile_move_params_are_refused() {
let mut s = plate();
let n = interior(&s.model);
let start = s.model.clone();
for p in [
json!({}),
json!({"nodes": [n]}),
json!({"by": [1, 0, 0]}),
json!({"nodes": [n], "by": [1, 0]}),
json!({"nodes": [n], "by": ["x", 0, 0]}),
json!({"nodes": [n], "by": [1e308, 1e308, 1e308]}),
json!({"nodes": [n], "by": [1, 0, 0], "to": [0, 0, 0]}),
json!({"nodes": [n], "center": [0, 0, 0], "radius": 1, "by": [1, 0, 0]}),
json!({"center": [0, 0, 0], "by": [1, 0, 0]}),
json!({"nodes": [n], "by": [1, 0, 0], "radius": -1}),
json!({"nodes": [n], "by": [1, 0, 0], "radius": "big"}),
json!({"nodes": [n], "by": [1, 0, 0], "strength": 0}),
json!({"nodes": [n], "by": [1, 0, 0], "strength": 2}),
json!({"nodes": [n], "by": [1, 0, 0], "falloff": "cubic"}),
json!({"nodes": [n], "by": [1, 0, 0], "propagate": "magic"}),
json!({"nodes": [n], "by": [1, 0, 0], "guard": "maybe"}),
json!({"nodes": [n], "by": [1, 0, 0], "on_geometry": "yes"}),
json!({"nodes": [99_999_999], "by": [1, 0, 0]}),
json!({"nodes": [-1], "by": [1, 0, 0]}),
json!({"nodes": [], "by": [1, 0, 0]}),
json!({"faces": [], "by": [1, 0, 0]}),
json!({"faces": [{"body": "Nope", "face": "F1"}], "by": [1, 0, 0]}),
json!({"center": [1e9, 0, 0], "radius": 1, "by": [1, 0, 0]}),
json!({"nodes": [n], "by": [1, 0, 0], "radius": 1e-300}),
json!({"center": [-1e300, -1e300, 0], "radius": 1e-300, "by": [1, 0, 0]}),
json!({"nodes": [n], "by": [1, 0, 0], "radius": 1e-300, "propagate": "harmonic"}),
json!({"nodes": [n], "by": [1, 0, 0], "min_quality": 1.5}),
json!({"nodes": [n], "by": [1, 0, 0], "min_quality": -0.1}),
json!({"nodes": [n], "by": [1, 0, 0], "guard": "none", "min_quality": 0.2}),
] {
assert!(s.execute("mesh.node.move", &p).is_err(), "{p} should be refused");
assert_eq!(s.model, start, "{p} changed the model");
}
}
/// A zero move, or one the guard scales back to nothing, changes nothing and records no undo
/// step; `min_quality` sets how far the guard lets a move go.
#[test]
fn a_move_of_nothing_records_no_undo_step_and_min_quality_is_adjustable() {
let mut s = plate();
let n = interior(&s.model);
let (journal, start) = (s.journal.len(), s.model.clone());
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0, 0, 0], "radius": 3.0})).unwrap();
assert_eq!((r["moved"].as_u64(), r["nothing_moved"].as_bool()), (Some(0), Some(true)), "{r}");
assert_eq!((s.journal.len(), &s.model), (journal, &start));
let scale = |s: &mut Session, q: f64| {
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [3.0, 0.0, 0.0], "min_quality": q})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
(r["scale"].as_f64().unwrap(), r["after"]["min_scaled_jacobian"].as_f64().unwrap())
};
let (loose, strict) = (scale(&mut s, 0.0), scale(&mut s, 0.5));
assert!(strict.0 < loose.0 && strict.1 >= 0.5 - 1e-9, "{loose:?} {strict:?}");
}
+16 -9
View File
@@ -61,7 +61,7 @@ const REMESH: &[&str] = &[
const DEFAULT_ITERATIONS: usize = 5;
/// The region's quality as JSON.
fn quality_json(q: &RegionQuality) -> Value {
pub(crate) fn quality_json(q: &RegionQuality) -> Value {
json!({
"elements": q.elements,
"min_scaled_jacobian": q.min_quality,
@@ -155,7 +155,7 @@ fn same_surface(a: &AnalyticSurface, b: &AnalyticSurface, tol: f64) -> bool {
}
/// The design's geometry as relaxation constraints.
struct DesignGeometry {
pub(crate) struct DesignGeometry {
/// Per body: its tessellation with exact surfaces.
bodies: Vec<Tessellation>,
/// Face ids: (body, B-rep face).
@@ -180,7 +180,7 @@ impl Constraints for DesignGeometry {
}
/// The constraints of `nodes` from the mesh's tags and the open design.
fn design_geometry(s: &Session, nodes: &BTreeSet<u32>) -> Result<DesignGeometry> {
pub(crate) fn design_geometry(s: &Session, nodes: &BTreeSet<u32>) -> Result<DesignGeometry> {
let tags = node_tags(&s.model, nodes);
let mut g = DesignGeometry { bodies: Vec::new(), faces: Vec::new(), curves: Vec::new(), freedom: BTreeMap::new() };
// Body name → (index into `g.bodies`, its exact surfaces), built when first needed.
@@ -298,7 +298,7 @@ fn body_nodes(model: &Model, p: &Value) -> Result<Option<(Vec<String>, BTreeSet<
Ok(Some((bodies, nodes)))
}
fn point(p: &Value, key: &str) -> Result<Option<[f64; 3]>> {
pub(crate) fn point(p: &Value, key: &str) -> Result<Option<[f64; 3]>> {
match super::opt::<[f64; 3]>(p, key)? {
Some(c) if c.iter().all(|x| x.is_finite()) => Ok(Some(c)),
Some(_) => Err(EngineError::param(key, ProblemKind::NotANumber)),
@@ -310,7 +310,7 @@ fn point(p: &Value, key: &str) -> Result<Option<[f64; 3]>> {
const MAX_FACES: usize = 10_000;
/// The `faces` parameter: a list of `{body, face}` B-rep face references.
fn face_refs(p: &Value) -> Result<Vec<GeometryRef>> {
pub(crate) fn face_refs(p: &Value) -> Result<Vec<GeometryRef>> {
let refs: Vec<GeometryRef> = super::req(p, "faces")?;
if refs.is_empty() || refs.len() > MAX_FACES {
return Err(EngineError::invalid("faces", format!("1 to {MAX_FACES} faces")));
@@ -337,7 +337,7 @@ fn face_tags<'a>(model: &Model, refs: &'a [GeometryRef]) -> (BTreeSet<u32>, Vec<
}
/// The listed faces with no tagged mesh, as `{body, face}` (the reply names them).
fn unmeshed_faces(model: &Model, p: &Value) -> Vec<Value> {
pub(crate) fn unmeshed_faces(model: &Model, p: &Value) -> Vec<Value> {
match p.get("faces").map(|_| face_refs(p)) {
Some(Ok(refs)) => face_tags(model, &refs).1.into_iter().map(|r| json!(r)).collect(),
_ => Vec::new(),
@@ -345,9 +345,9 @@ fn unmeshed_faces(model: &Model, p: &Value) -> Vec<Value> {
}
/// The nodes on the B-rep faces `refs` (from the mesh's geometry tags: element faces on them and
/// shell elements meshing them) and the nodes of every element touching those. Faces with no
/// tagged mesh are left out (the reply names them); none with one is an error.
fn face_nodes(model: &Model, refs: &[GeometryRef]) -> Result<BTreeSet<u32>> {
/// shell elements meshing them). Faces with no tagged mesh are left out (the reply names them);
/// none with one is an error.
pub(crate) fn nodes_on_faces(model: &Model, refs: &[GeometryRef]) -> Result<BTreeSet<u32>> {
let c = model.classification();
let (tags, missing) = face_tags(model, refs);
if tags.is_empty() {
@@ -369,6 +369,13 @@ fn face_nodes(model: &Model, refs: &[GeometryRef]) -> Result<BTreeSet<u32>> {
on.extend(local.iter().filter_map(|&i| e.nodes.get(i).copied()));
}
}
Ok(on)
}
/// The nodes on the B-rep faces `refs` ([`nodes_on_faces`]) and the nodes of every element
/// touching those.
fn face_nodes(model: &Model, refs: &[GeometryRef]) -> Result<BTreeSet<u32>> {
let on = nodes_on_faces(model, refs)?;
let mut out = on.clone();
for e in model.elements().values() {
if e.nodes.iter().any(|n| on.contains(n)) {
+2 -1
View File
@@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s
shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line,
the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
85 ready, 36 coming soon.
86 ready, 36 coming soon.
## Geometry
@@ -55,6 +55,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Improve | Untangle — Repair inverted elements | coming soon (M6) | |
| Improve | Layers — Boundary padding and layers through thin walls | coming soon (M6) | |
| Improve | Relax Nodes — Relax the nodes inside a sphere to fix spikes and slivers; surface nodes slide on the geometry; kept as a field to apply again after a remesh | ready | `mesh.node.relax`, `mesh.node.relax.apply` |
| Improve | Move Nodes — Drag nodes of the mesh, the nodes round them following by a falloff or harmonically, on the geometry; the original drawn as a ghost and the elements coloured by how their quality changes | ready | `mesh.node.move` |
| Edit | Node — Add a node | ready | `mesh.node.add`, `mesh.node.remove` |
| Edit | Element — Add an element on existing nodes | ready | `mesh.element.add`, `mesh.element.remove`, `mesh.clear` |
| Edit | Mirror Mesh — Mirror a body's mesh across a plane, joined on the plane (mesh half a symmetric part) | ready | `mesh.mirror` |