mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Isocurve Analysis draws each face's exact iso curves, cut to its trims (Refs #770)
The analysis sliced the display mesh with planes: faceted, approximate, and broken at triangle seams and trims; the window drew it as a 2D overlay without depth while the tool was active. Now one kernel routine (kernel::iso) clips a surface's u = const or v = const line to a face's pcurve loops; the curve through a point (sketch projection) and the analysis grid (count each way, evenly across the face's extent) both use it. The loop polygon follows the loop's order (a pcurve the loop runs against is walked backwards), which the inside test got wrong before, and closes over vertex-loop poles. ModelBody caches the lines per face, count and display quality. The session keeps the picked faces as view state (inspect.isocurve add/clear, hostile counts refused); the viewport, ui.render and snapshots draw them depth tested in the accent colour. Curvature combs on faces follow the same exact lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,10 @@ use crate::{AxisRef, DocError, Document, Feature, FeatureId, FeatureKind, HoleKi
|
||||
|
||||
type JointFaces = (Body, kernel::Result<Arc<[kernel::FaceInfo]>>);
|
||||
|
||||
/// A body's isoparametric curves worked out so far ([`ModelBody::iso_lines`]), with the body they
|
||||
/// were worked out on: by face, count each way and display quality.
|
||||
type IsoLines = (Option<Body>, std::collections::HashMap<(usize, usize, usize), Arc<[kernel::IsoLine]>>);
|
||||
|
||||
/// A body in the evaluated model.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ModelBody {
|
||||
@@ -72,6 +76,8 @@ pub struct ModelBody {
|
||||
/// [`Body::is_surface`] of `body` (with the body it was measured on), worked out once and
|
||||
/// shared by clones.
|
||||
surface: Arc<OnceLock<(Body, bool)>>,
|
||||
/// [`ModelBody::iso_lines`] worked out so far, shared by clones.
|
||||
iso: Arc<std::sync::Mutex<IsoLines>>,
|
||||
}
|
||||
|
||||
impl ModelBody {
|
||||
@@ -98,6 +104,7 @@ impl ModelBody {
|
||||
outline: Arc::new(OnceLock::new()),
|
||||
measured: Arc::new(OnceLock::new()),
|
||||
surface: Arc::new(OnceLock::new()),
|
||||
iso: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +333,31 @@ impl ModelBody {
|
||||
pub fn cached_mesh(&self) -> Option<Arc<Mesh>> {
|
||||
self.mesh.get().cloned()
|
||||
}
|
||||
|
||||
/// The isoparametric curves of face `face` for Isocurve Analysis: `count` each way across
|
||||
/// the face, exact and cut to its trims ([`kernel::iso_grid`]), their points at the chord
|
||||
/// tolerance of the viewport's display quality. Worked out once per face, count and display
|
||||
/// quality, and shared by every clone of this body (timeline snapshots, world states). A mesh
|
||||
/// body has no surfaces, so none.
|
||||
pub fn iso_lines(&self, face: usize, count: usize) -> Result<Arc<[kernel::IsoLine]>> {
|
||||
if self.body.is_mesh() {
|
||||
return Err(DocError::Invalid(format!("{} is a mesh body: its faces have no surface parameters, so no isoparametric curves", self.name)));
|
||||
}
|
||||
let quality = crate::display_quality::current();
|
||||
let key = (face, count, quality.index());
|
||||
let make = || kernel::iso_grid(&self.body, face, count, quality.chord(self.body.size())).map(Arc::from);
|
||||
let Ok(mut cache) = self.iso.lock() else { return Ok(make()?) };
|
||||
if !cache.0.as_ref().is_some_and(|b| b.same(&self.body)) {
|
||||
// A body replaced in this cell's holder since: what was worked out was on another.
|
||||
*cache = (Some(self.body.clone()), Default::default());
|
||||
}
|
||||
if let Some(lines) = cache.1.get(&key) {
|
||||
return Ok(lines.clone());
|
||||
}
|
||||
let lines = make()?;
|
||||
cache.1.insert(key, lines.clone());
|
||||
Ok(lines)
|
||||
}
|
||||
}
|
||||
|
||||
/// A solved sketch in the evaluated model.
|
||||
|
||||
@@ -67,7 +67,6 @@ pub mod render_optics;
|
||||
pub mod render_resources;
|
||||
pub mod render_settings;
|
||||
pub mod render_surface;
|
||||
pub use project3d::iso_grid;
|
||||
pub mod sheet;
|
||||
pub mod text_params;
|
||||
pub mod thread_forms;
|
||||
|
||||
@@ -191,55 +191,6 @@ pub(crate) fn iso_curve(m: &Mesh, f: u32, p: Vec3, dir: &str) -> Vec<Vec<Vec3>>
|
||||
if len(&a) >= len(&b) { a } else { b }
|
||||
}
|
||||
|
||||
/// Isocurve analysis: `count` curves each way across face `f` (picked at `p`), as world
|
||||
/// polylines. Each family is the face cut by parallel planes spaced evenly over the face.
|
||||
pub fn iso_grid(m: &Mesh, f: u32, p: Vec3, count: usize) -> Vec<Vec<Vec3>> {
|
||||
let count = count.clamp(1, 100);
|
||||
let verts: Vec<Vec3> = m.triangles.iter().zip(&m.tri_face).filter(|(_, x)| **x == f).filter_map(|(t, _)| m.tri(t)).flatten().collect();
|
||||
if verts.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for dir in ["u", "v"] {
|
||||
// The family's cutting direction, from the curve through p.
|
||||
let Some(first) = iso_curve(m, f, p, dir).into_iter().next() else { continue };
|
||||
let pts: Vec<Vec3> = first.to_vec();
|
||||
// Normal of the plane that curve lies in.
|
||||
let Some(c) = pts.first().copied() else { continue };
|
||||
let mut n = Vec3::ZERO;
|
||||
for w in pts.windows(2) {
|
||||
n += (w[0] - c).cross(w[1] - c);
|
||||
}
|
||||
let cut_n = match n.normalized() {
|
||||
Some(n) => n,
|
||||
// A straight curve: across it, within the face.
|
||||
None => {
|
||||
let d = pts.last().copied().unwrap_or(c) - c;
|
||||
let np = (verts[1] - verts[0]).cross(verts[2] - verts[0]);
|
||||
match d.cross(np).normalized() {
|
||||
Some(x) => x,
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
};
|
||||
let (lo, hi) = verts.iter().map(|v| v.dot(cut_n)).fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(x), b.max(x)));
|
||||
if !(hi - lo > 1e-9) {
|
||||
continue;
|
||||
}
|
||||
for k in 1..=count {
|
||||
let d = lo + (hi - lo) * k as f64 / (count + 1) as f64;
|
||||
let Some(pl) = Plane::from_normal(cut_n * d, cut_n) else { continue };
|
||||
for g in crate::project::section(&pl, m, Some(f)) {
|
||||
let w: Vec<Vec3> = link_polyline(&g).into_iter().map(|q| pl.to_world(q)).collect();
|
||||
if w.len() >= 2 {
|
||||
out.push(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Join polylines whose ends meet into longer ones.
|
||||
fn join_wires(mut ws: Vec<Vec<Vec3>>, tol: f64) -> Vec<Vec<Vec3>> {
|
||||
let mut out: Vec<Vec<Vec3>> = Vec::new();
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
|
||||
use solidcraft_geom::{Aabb3, Seg2, Vec2, Vec3};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, num, string_list, vec3};
|
||||
use crate::params::{bad, bool_, num, string_list, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
@@ -23,7 +23,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "INSPECT")
|
||||
.icon("iso_analysis")
|
||||
.noundo()
|
||||
.params("faces: [[x,y,z]…] points on faces; count?: curves each way (default 8): the faces' isoparametric curves"),
|
||||
.params(
|
||||
"faces: [[x,y,z]…] points on faces (1…100); count?: whole number of curves each way, 1…100 (default 8); \
|
||||
add?: true keeps the faces already shown; clear?: true turns the analysis off. → each face's isoparametric \
|
||||
curves, u = const and v = const of its surface, evenly spaced across it and cut to its trims, as points \
|
||||
(`curves`) and per face (`faces`); the viewport draws them",
|
||||
),
|
||||
CommandSpec::new("inspect.center_of_mass", "Center of Mass", center_of_mass)
|
||||
.at("SKETCH", "INSPECT")
|
||||
.icon("center_of_mass")
|
||||
@@ -121,12 +126,12 @@ fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
num(p, "count").filter(|c| c.is_finite()).map(|c| c.clamp(1.0, 50.0) as usize).ok_or_else(|| bad(cmd, "`count` must be a number"))?
|
||||
}
|
||||
};
|
||||
let world = s.world_state()?;
|
||||
for v in faces.iter().take(100) {
|
||||
let at = vec3(v).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?;
|
||||
let (bi, f) = face_at(&st, at).ok_or_else(|| bad(cmd, "no face there"))?;
|
||||
let m = st.bodies.get(bi).map(|b| b.mesh()).ok_or_else(|| bad(cmd, "that face's body is not evaluated"))?;
|
||||
for (k, w) in solidcraft_doc::iso_grid(&m, f, at, count).iter().enumerate() {
|
||||
combs.push((format!("face@{},{},{}#{k}", at.x, at.y, at.z), teeth_3d(w)));
|
||||
let (body, face) = iso_face_at(s, &world, at).ok_or_else(|| bad(cmd, "no face there"))?;
|
||||
for (k, line) in body.iso_lines(face, count)?.iter().enumerate() {
|
||||
combs.push((format!("face@{},{},{}#{k}", at.x, at.y, at.z), teeth_3d(&line.points)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,24 +301,92 @@ fn minimum_radius(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Isocurves each way when the call does not say (`count`), and most faces one call may pick.
|
||||
const DEFAULT_ISO_COUNT: usize = 8;
|
||||
const MAX_ISO_FACES: usize = 100;
|
||||
|
||||
/// The visible model body whose face is nearest `at` (within its pick tolerance), and that face.
|
||||
fn iso_face_at<'a>(s: &Session, st: &'a solidcraft_doc::ModelState, at: Vec3) -> Option<(&'a solidcraft_doc::ModelBody, usize)> {
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| s.body_visible(&b.name))
|
||||
.filter_map(|b| {
|
||||
let (d, f) = solidcraft_doc::appearance::nearest_face(b, at)?;
|
||||
(d <= solidcraft_doc::appearance::pick_tolerance(b)).then_some((d, b, f))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, b, f)| (b, f))
|
||||
}
|
||||
|
||||
/// `inspect.isocurve`: the picked faces' isoparametric curves (`count` each way, exact and cut to
|
||||
/// each face's trims, [`solidcraft_doc::ModelBody::iso_lines`]), kept so the viewport draws them.
|
||||
/// View state only, like the other analyses: the design and its undo history are untouched.
|
||||
fn isocurves(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.isocurve";
|
||||
let count = num(p, "count").map(|c| c.clamp(1.0, 100.0) as usize).unwrap_or(8);
|
||||
let faces = p.get("faces").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?;
|
||||
if faces.is_empty() {
|
||||
return Err(bad(cmd, "`faces` is empty"));
|
||||
if bool_(p, "clear").unwrap_or(false) {
|
||||
s.isocurves.clear();
|
||||
s.revision += 1;
|
||||
return Ok(json!({"analysis": "isocurve", "cleared": true}));
|
||||
}
|
||||
let st = s.model.state();
|
||||
let mut out = Vec::new();
|
||||
for v in faces.iter().take(100) {
|
||||
let at = vec3(v).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?;
|
||||
let (bi, f) = face_at(&st, at).ok_or_else(|| bad(cmd, "no face there"))?;
|
||||
let m = st.bodies.get(bi).map(|b| b.mesh()).ok_or_else(|| bad(cmd, "body"))?;
|
||||
for w in solidcraft_doc::iso_grid(&m, f, at, count) {
|
||||
out.push(w.iter().map(|q| [q.x, q.y, q.z]).collect::<Vec<_>>());
|
||||
let max = crate::kernel::MAX_ISO_COUNT;
|
||||
let count = match p.get("count") {
|
||||
None | Some(Value::Null) => DEFAULT_ISO_COUNT,
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|c| c.fract() == 0.0 && (1.0..=max as f64).contains(c))
|
||||
.map(|c| c as usize)
|
||||
.ok_or_else(|| bad(cmd, format!("`count` must be a whole number of curves each way, 1…{max}")))?,
|
||||
};
|
||||
let faces = p.get("faces").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z] on faces"))?;
|
||||
if faces.is_empty() || faces.len() > MAX_ISO_FACES {
|
||||
return Err(bad(cmd, format!("`faces` must list 1…{MAX_ISO_FACES} points [x,y,z] on faces")));
|
||||
}
|
||||
let st = s.world_state()?;
|
||||
let (mut picks, mut curves, mut shown) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for v in faces {
|
||||
let at = vec3(v).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z] on faces"))?;
|
||||
let (body, face) = iso_face_at(s, &st, at).ok_or_else(|| bad(cmd, "no face there"))?;
|
||||
let lines = body.iso_lines(face, count)?;
|
||||
let along_u = lines.iter().filter(|l| l.along_u).count();
|
||||
shown.push(json!({"body": body.name, "face": face, "count": count, "u_curves": along_u, "v_curves": lines.len() - along_u}));
|
||||
curves.extend(lines.iter().map(|l| l.points.iter().map(|q| [q.x, q.y, q.z]).collect::<Vec<_>>()));
|
||||
picks.push((face, crate::IsoPick { body: body.name.clone(), at, count }));
|
||||
}
|
||||
if bool_(p, "add").unwrap_or(false) {
|
||||
// A face picked again shows its new count, once.
|
||||
let again = |k: &crate::IsoPick| {
|
||||
picks
|
||||
.iter()
|
||||
.any(|(f, n)| n.body == k.body && st.body(&k.body).and_then(|b| solidcraft_doc::appearance::face_index_at(b, k.at)) == Some(*f))
|
||||
};
|
||||
s.isocurves.retain(|k| !again(k));
|
||||
} else {
|
||||
s.isocurves.clear();
|
||||
}
|
||||
s.isocurves.extend(picks.into_iter().map(|(_, k)| k));
|
||||
s.revision += 1;
|
||||
Ok(json!({"curves": curves, "faces": shown}))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The curves Isocurve Analysis shows, for the viewport to draw: those of each face in
|
||||
/// [`Session::isocurves`] of a visible body, found again from its picked point (worked out
|
||||
/// once per face and count, [`solidcraft_doc::ModelBody::iso_lines`]). A pick whose body or
|
||||
/// face is gone shows nothing.
|
||||
pub fn isocurve_lines(&self) -> Vec<std::sync::Arc<[crate::kernel::IsoLine]>> {
|
||||
if self.isocurves.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(st) = self.world_state() else { return Vec::new() };
|
||||
self.isocurves
|
||||
.iter()
|
||||
.filter_map(|k| {
|
||||
let b = st.body(&k.body).filter(|b| self.body_visible(&b.name))?;
|
||||
let face = solidcraft_doc::appearance::face_index_at(b, k.at)?;
|
||||
b.iso_lines(face, k.count).ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Ok(json!({"curves": out}))
|
||||
}
|
||||
|
||||
fn center_of_mass(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -402,7 +475,7 @@ mod tests {
|
||||
let r = s.execute("inspect.isocurve", &json!({"faces": [[58, 0, 5]], "count": 4})).unwrap();
|
||||
let curves = r["curves"].as_array().unwrap();
|
||||
let ring =
|
||||
|c: &Value| c.as_array().unwrap().iter().all(|q| ((q[0].as_f64().unwrap() - 50.0).hypot(q[1].as_f64().unwrap()) - 8.0).abs() < 0.2);
|
||||
|c: &Value| c.as_array().unwrap().iter().all(|q| ((q[0].as_f64().unwrap() - 50.0).hypot(q[1].as_f64().unwrap()) - 8.0).abs() < 1e-6);
|
||||
assert!(curves.len() >= 8, "{}", curves.len());
|
||||
assert!(curves.iter().all(ring), "every curve lies on the cylinder");
|
||||
let flat = |c: &Value| {
|
||||
@@ -412,4 +485,127 @@ mod tests {
|
||||
assert!(curves.iter().filter(|c| flat(c)).count() >= 4, "rings at constant height");
|
||||
assert!(s.execute("inspect.isocurve", &json!({"faces": []})).is_err());
|
||||
}
|
||||
|
||||
/// Points of a reply's `curves`.
|
||||
fn curve_points(r: &Value) -> Vec<Vec<[f64; 3]>> {
|
||||
r["curves"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c.as_array().unwrap().iter().map(|q| [0, 1, 2].map(|k| q[k].as_f64().unwrap())).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Isocurve Analysis draws a face's exact isoparametric curves, `count` each way, cut to its
|
||||
/// trims: on a cylinder's side they lie on the cylinder and the lines along it stop where they
|
||||
/// meet a hole through it; the view state keeps the face, and hostile counts are refused.
|
||||
#[test]
|
||||
fn isocurves_are_exact_and_stop_at_a_hole() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.cylinder", &json!({"radius": 10, "height": 40})).unwrap();
|
||||
// Untrimmed but for its ends: `count` curves each way, each one piece.
|
||||
let r = s.execute("inspect.isocurve", &json!({"faces": [[10, 0, 20]], "count": 5})).unwrap();
|
||||
let face = &r["faces"][0];
|
||||
assert_eq!((face["u_curves"].as_u64(), face["v_curves"].as_u64()), (Some(5), Some(5)), "{face}");
|
||||
assert_eq!(s.isocurves.len(), 1);
|
||||
assert_eq!(s.isocurve_lines().iter().map(|l| l.len()).sum::<usize>(), 10);
|
||||
// A hole of radius 3 through it along X, at half height.
|
||||
s.execute("solid.cylinder", &json!({"radius": 3, "height": 30, "base": [-15, 0, 20], "axis": "X", "operation": "cut"})).unwrap();
|
||||
let r = s.execute("inspect.isocurve", &json!({"faces": [[0, 10, 20]], "count": 9})).unwrap();
|
||||
let curves = curve_points(&r);
|
||||
assert!(!curves.is_empty(), "{r}");
|
||||
let from_hole = |q: &[f64; 3]| q[1].hypot(q[2] - 20.0) - 3.0;
|
||||
for c in &curves {
|
||||
for q in c {
|
||||
assert!((q[0].hypot(q[1]) - 10.0).abs() < 1e-6, "on the cylinder: {q:?}");
|
||||
assert!(from_hole(q) > -1e-3, "not in the hole: {q:?}");
|
||||
assert!((-1e-6..=40.0 + 1e-6).contains(&q[2]), "within its ends: {q:?}");
|
||||
}
|
||||
}
|
||||
// The lines along the axis that cross the hole stop at its rim: their ends there lie on
|
||||
// the hole's edge.
|
||||
let mut at_rim = 0;
|
||||
for c in &curves {
|
||||
for q in [c.first().unwrap(), c.last().unwrap()] {
|
||||
if q[2] > 1e-3 && q[2] < 40.0 - 1e-3 && from_hole(q) < 0.5 {
|
||||
assert!(from_hole(q).abs() < 1e-3, "an end on the hole's edge: {q:?} is {} from it", from_hole(q));
|
||||
at_rim += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(at_rim >= 4, "the lines crossing the hole stop at it ({at_rim} ends there)");
|
||||
// Picking the face again replaces it; `add` keeps the others; `clear` ends it.
|
||||
s.execute("inspect.isocurve", &json!({"faces": [[0, -10, 5]], "count": 3, "add": true})).unwrap();
|
||||
let shown = s.isocurves.len();
|
||||
s.execute("inspect.isocurve", &json!({"faces": [[0, -10, 5]], "count": 4, "add": true})).unwrap();
|
||||
assert_eq!(s.isocurves.len(), shown, "the same face again replaces its curves");
|
||||
assert_eq!(s.isocurves.last().map(|k| k.count), Some(4));
|
||||
let before = s.doc.clone();
|
||||
s.execute("inspect.isocurve", &json!({"clear": true})).unwrap();
|
||||
assert!(s.isocurves.is_empty() && s.isocurve_lines().is_empty());
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.doc), "view state only: the design is untouched");
|
||||
// Hostile counts and picks.
|
||||
for count in [json!(0), json!(-3), json!(2.5), json!(101), json!(1e300), json!("8"), json!([4])] {
|
||||
assert!(s.execute("inspect.isocurve", &json!({"faces": [[0, 10, 20]], "count": count})).is_err(), "count {count}");
|
||||
}
|
||||
assert!(s.execute("inspect.isocurve", &json!({"faces": [[500, 0, 0]]})).is_err(), "no face there");
|
||||
assert!(s.execute("inspect.isocurve", &json!({"faces": [["x", 0, 0]]})).is_err());
|
||||
assert!(s.execute("inspect.isocurve", &json!({"faces": vec![[0, 10, 20]; 101]})).is_err());
|
||||
assert!(s.isocurves.is_empty(), "a refused call shows nothing");
|
||||
}
|
||||
|
||||
/// A sphere's face is closed by its seam and poles: its curves are the circles round it and
|
||||
/// the meridians, each meridian from pole to pole.
|
||||
#[test]
|
||||
fn isocurves_of_a_sphere_meet_at_its_poles() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.sphere", &json!({"radius": 10})).unwrap();
|
||||
let r = s.execute("inspect.isocurve", &json!({"faces": [[10, 0, 0]], "count": 5})).unwrap();
|
||||
let face = &r["faces"][0];
|
||||
assert_eq!((face["u_curves"].as_u64(), face["v_curves"].as_u64()), (Some(5), Some(5)), "{face}");
|
||||
let curves = curve_points(&r);
|
||||
let len = |q: &[f64; 3]| q[0].hypot(q[1]).hypot(q[2]);
|
||||
assert!(curves.iter().flatten().all(|q| (len(q) - 10.0).abs() < 1e-6), "on the sphere");
|
||||
let pole = |q: &[f64; 3]| q[0].hypot(q[1]) < 1e-6 && (q[2].abs() - 10.0).abs() < 1e-6;
|
||||
let meridians = curves.iter().filter(|c| pole(c.first().unwrap()) && pole(c.last().unwrap())).count();
|
||||
assert_eq!(meridians, 5, "the meridians run from pole to pole");
|
||||
}
|
||||
|
||||
/// On a trimmed B-spline face (a spline profile's extruded side, with a hole through it) the
|
||||
/// isocurves are the surface's own rows and columns and stay inside the face's loops.
|
||||
#[test]
|
||||
fn isocurves_of_a_trimmed_spline_face_stay_inside_it() {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute("sketch.spline.fit_point", &json!({"points": [[0, 0], [20, 8], [40, 0]]})).unwrap();
|
||||
s.execute("sketch.line", &json!({"points": [[40, 0], [0, 0]]})).unwrap();
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
s.execute("solid.extrude", &json!({"distance": 30})).unwrap();
|
||||
s.execute("solid.cylinder", &json!({"radius": 3, "height": 30, "base": [20, -10, 15], "axis": "Y", "operation": "cut"})).unwrap();
|
||||
let r = s.execute("inspect.isocurve", &json!({"faces": [[20, 8, 5]], "count": 12})).unwrap();
|
||||
let face = &r["faces"][0];
|
||||
assert!(face["u_curves"].as_u64().unwrap() >= 12 && face["v_curves"].as_u64().unwrap() >= 12, "{face}");
|
||||
let st = s.model.state();
|
||||
let body = &st.bodies[0];
|
||||
let mesh = body.mesh();
|
||||
let tol = body.display_tol();
|
||||
let spline = face["face"].as_u64().unwrap() as u32;
|
||||
for c in curve_points(&r) {
|
||||
for q in &c {
|
||||
let p = solidcraft_geom::Vec3::new(q[0], q[1], q[2]);
|
||||
// On that face (within the display mesh's sag), within its extent, clear of the hole.
|
||||
let d = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, f)| **f == spline)
|
||||
.filter_map(|(t, _)| mesh.tri(t))
|
||||
.map(|[a, b, c]| super::closest_on_triangle(p, a, b, c).dist(p))
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
assert!(d < 2.0 * tol, "on the spline face: {q:?} is {d} from it");
|
||||
assert!((-1e-6..=30.0 + 1e-6).contains(&q[2]) && q[1] > -1e-6, "{q:?}");
|
||||
assert!((q[0] - 20.0).hypot(q[2] - 15.0) > 3.0 - 1e-3, "not in the hole: {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,6 +418,9 @@ pub struct Session {
|
||||
/// The seams `inspect.continuity` last measured, for the viewport to shade. View state: not
|
||||
/// part of the design and not an undo step.
|
||||
pub continuity: Vec<ContinuitySeam>,
|
||||
/// The faces Isocurve Analysis shows the isoparametric curves of, for the viewport to draw
|
||||
/// ([`Session::isocurve_lines`]). View state: not part of the design and not an undo step.
|
||||
pub isocurves: Vec<IsoPick>,
|
||||
/// What is hidden or shown in the view (`browser.visibility`): saved with the design and an
|
||||
/// undo step of its own ([`Snapshot::visibility`]). A design brings its saved visibility when
|
||||
/// it opens (`Session::new`).
|
||||
@@ -502,6 +505,15 @@ pub struct ContinuitySeam {
|
||||
pub verdict: kernel::SeamVerdict,
|
||||
}
|
||||
|
||||
/// A face Isocurve Analysis shows: the body, the picked point on the face (its face is found
|
||||
/// from it again whenever the body changes) and how many curves each way. View state only.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
pub struct IsoPick {
|
||||
pub body: String,
|
||||
pub at: Vec3,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
impl SurfaceAnalysis {
|
||||
/// How a picture shades the faces for this analysis.
|
||||
pub fn mode(self) -> render::AnalysisMode {
|
||||
@@ -594,6 +606,7 @@ impl Session {
|
||||
colored_components: None,
|
||||
analysis: None,
|
||||
continuity: Vec::new(),
|
||||
isocurves: Vec::new(),
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
replacements: 0,
|
||||
@@ -1366,6 +1379,7 @@ impl Session {
|
||||
colored_components: None,
|
||||
analysis: None,
|
||||
continuity: Vec::new(),
|
||||
isocurves: Vec::new(),
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
replacements: 0,
|
||||
|
||||
@@ -23,6 +23,8 @@ pub mod colors {
|
||||
/// Fixed (locked) geometry.
|
||||
pub const SKETCH_LOCKED: Rgb = Rgb(0, 140, 60);
|
||||
pub const SKETCH_CENTERLINE: Rgb = Rgb(200, 110, 60);
|
||||
/// Isocurve Analysis curves: the light theme's accent.
|
||||
pub const ISOCURVE: Rgb = Rgb(38, 120, 218);
|
||||
/// Linked (projected) reference geometry.
|
||||
pub const SKETCH_PROJECTED: Rgb = Rgb(150, 60, 190);
|
||||
/// Linked geometry whose reference was lost.
|
||||
@@ -70,6 +72,8 @@ pub struct Palette {
|
||||
pub axes: [Rgb; 3],
|
||||
/// What the section cap is tinted toward.
|
||||
pub cap: Rgb,
|
||||
/// Isocurve Analysis: the faces' isoparametric curves.
|
||||
pub isocurve: Rgb,
|
||||
/// The surface analysis colours.
|
||||
pub analysis: AnalysisColors,
|
||||
}
|
||||
@@ -93,6 +97,7 @@ impl Palette {
|
||||
sketch_centerline: colors::SKETCH_CENTERLINE,
|
||||
axes: [colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z],
|
||||
cap: colors::SECTION_CAP,
|
||||
isocurve: colors::ISOCURVE,
|
||||
analysis: AnalysisColors::DEFAULT,
|
||||
};
|
||||
}
|
||||
@@ -694,6 +699,10 @@ fn build_scene(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Isocurve Analysis: the isoparametric curves of each face it shows, on the faces.
|
||||
for line in s.isocurve_lines().iter().flat_map(|lines| lines.iter()) {
|
||||
sc.lines.push(SceneLine { points: line.points.clone(), color: p.isocurve, alpha: 1.0, width: 1.4, on_top: false });
|
||||
}
|
||||
for ss in &st.sketches {
|
||||
let active = s.active_sketch == Some(ss.feature);
|
||||
if !sketch_shown(s, ss.feature) {
|
||||
|
||||
@@ -93,6 +93,7 @@ const MARK: Palette = Palette {
|
||||
sketch_centerline: Rgb(43, 44, 45),
|
||||
axes: [Rgb(46, 47, 48), Rgb(49, 50, 51), Rgb(52, 53, 54)],
|
||||
cap: Rgb(55, 56, 57),
|
||||
isocurve: Rgb(58, 59, 60),
|
||||
analysis: solidcraft_render::AnalysisColors {
|
||||
draft: [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]],
|
||||
access: [[0.11, 0.12, 0.13], [0.14, 0.15, 0.16]],
|
||||
@@ -123,7 +124,7 @@ fn the_default_palette_is_the_colour_table() {
|
||||
(p.sketch_projected, p.sketch_lost, p.sketch_construction, p.sketch_centerline),
|
||||
(colors::SKETCH_PROJECTED, colors::SKETCH_LOST, colors::SKETCH_CONSTRUCTION, colors::SKETCH_CENTERLINE)
|
||||
);
|
||||
assert_eq!((p.axes, p.cap), ([colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z], colors::SECTION_CAP));
|
||||
assert_eq!((p.axes, p.cap, p.isocurve), ([colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z], colors::SECTION_CAP, colors::ISOCURVE));
|
||||
assert_eq!(p.analysis, solidcraft_render::AnalysisColors::DEFAULT);
|
||||
}
|
||||
|
||||
@@ -204,6 +205,8 @@ fn every_scene_colour_comes_from_its_palette_and_appearances_stay() {
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute("sketch.line", &json!({"points": [[0, -20], [30, -20]]})).unwrap();
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
// Isocurve Analysis on Plain's side face.
|
||||
s.execute("inspect.isocurve", &json!({"faces": [[45, 0, 5]], "count": 2})).unwrap();
|
||||
s.selection = vec![Sel::Body { name: "Picked".into() }];
|
||||
s.section = Some((Vec3::new(0., 0., 5.), Vec3::Z));
|
||||
let cam = home_camera(&s).unwrap();
|
||||
@@ -229,11 +232,12 @@ fn every_scene_colour_comes_from_its_palette_and_appearances_stay() {
|
||||
MARK.sketch_lost,
|
||||
MARK.sketch_construction,
|
||||
MARK.sketch_centerline,
|
||||
MARK.isocurve,
|
||||
];
|
||||
let all_lines = || marked.grid.iter().chain(&marked.lines);
|
||||
let stray: Vec<Rgb> = all_lines().map(|l| l.color).filter(|c| !allowed.contains(c)).collect();
|
||||
assert!(stray.is_empty(), "line colours not from the palette: {stray:?}");
|
||||
for want in [MARK.grid, MARK.grid_major, MARK.edge, MARK.axes[0], MARK.axes[1], MARK.axes[2], MARK.sketch_done] {
|
||||
for want in [MARK.grid, MARK.grid_major, MARK.edge, MARK.axes[0], MARK.axes[1], MARK.axes[2], MARK.sketch_done, MARK.isocurve] {
|
||||
assert!(all_lines().any(|l| l.color == want), "no line in {want:?}");
|
||||
}
|
||||
assert!(marked.grid.iter().all(|l| [MARK.grid, MARK.grid_major].contains(&l.color)), "only the grid is drawn under the model");
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Isoparametric curves of a face: the curves of its surface along which one parameter is held
|
||||
//! (u = const or v = const), each exactly as a record curve, cut to the face by its trims (the
|
||||
//! pcurves of its loops in (u, v)). One routine clips an iso line to a face ([`FaceTrims::clip`]);
|
||||
//! the curve through a point ([`iso_curves`], projected into sketches) and the analysis grid
|
||||
//! across a face ([`iso_grid`], drawn by Isocurve Analysis) both use it.
|
||||
//!
|
||||
//! Seams, poles and periods need nothing of their own: a face's loops and pcurves lie in one lift
|
||||
//! of its surface's (u, v) inside its box ([`brep::Face::domain`]), a seam edge is in the loop
|
||||
//! twice (once on each side of the box), and a pole is a degenerate edge or a vertex loop on the
|
||||
//! box's side, where the outer loop's polygon closes. Every iso line of a closed surface is
|
||||
//! therefore one line across the box, cut where it crosses the loops.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::brep;
|
||||
use solidcraft_geom::brep::eval::SurfaceEval;
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::point_path::{MAX_CURVE_POINTS, curve_path};
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
/// Samples per knot span (and at least this many per pcurve) where the iso curves look for the
|
||||
/// crossings of a face's loops and classify the face's inside.
|
||||
const ISO_SAMPLES: usize = 64;
|
||||
|
||||
/// Most iso curves each way [`iso_grid`] draws across one face.
|
||||
pub const MAX_ISO_COUNT: usize = 100;
|
||||
|
||||
/// One piece of an isoparametric curve of a face, inside the face.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct IsoLine {
|
||||
/// The curve runs along the surface's first parameter (u runs, v is held), or its second.
|
||||
pub along_u: bool,
|
||||
/// The parameter held: v when `along_u`, else u.
|
||||
pub fixed: f64,
|
||||
/// The exact record curve on `interval` of its own parameter.
|
||||
pub curve: brep::Curve,
|
||||
pub interval: [f64; 2],
|
||||
/// Its points from the start of `interval` to its end, at the chord tolerance asked for.
|
||||
pub points: Vec<Vec3>,
|
||||
}
|
||||
|
||||
/// A face's surface and its loops in (u, v), read once for any number of iso lines.
|
||||
struct FaceTrims<'a> {
|
||||
surface: &'a brep::Surface,
|
||||
eval: SurfaceEval,
|
||||
domain: [[f64; 2]; 2],
|
||||
/// Each edge loop's pcurves, each with its samples ([`pcurve_samples`]) and whether the loop
|
||||
/// runs along it (from its interval's start to its end).
|
||||
loops: Vec<Vec<(&'a brep::Curve2, Vec<(f64, [f64; 2])>, bool)>>,
|
||||
/// Each edge loop as a closed polygon in (u, v), for telling the face's inside.
|
||||
polygons: Vec<Vec<[f64; 2]>>,
|
||||
}
|
||||
|
||||
impl<'a> FaceTrims<'a> {
|
||||
fn new(body: &'a Body, face: usize) -> Result<FaceTrims<'a>> {
|
||||
let f = body.record().faces.get(face).ok_or_else(|| KernelError::Invalid(format!("the body has no face {face}")))?;
|
||||
let surface = f.parameters();
|
||||
let eval = SurfaceEval::new(surface).map_err(|e| KernelError::Failed(format!("iso curve: {e}")))?;
|
||||
let loops: Vec<Vec<(&brep::Curve2, Vec<(f64, [f64; 2])>, bool)>> = f
|
||||
.loops
|
||||
.iter()
|
||||
.filter_map(|l| match l {
|
||||
brep::Loop::Edges { edges } => Some(edges.iter().map(|e| (&e.pcurve, pcurve_samples(&e.pcurve), e.forward)).collect()),
|
||||
// A pole: the outer loop's polygon closes along the box's side there.
|
||||
brep::Loop::Vertex { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
let polygons = loops
|
||||
.iter()
|
||||
.map(|l| {
|
||||
// In the loop's order: a pcurve runs along its edge's interval whichever way the
|
||||
// loop does, so one the loop runs against is walked backwards.
|
||||
let mut poly: Vec<[f64; 2]> = Vec::new();
|
||||
for (_, samples, forward) in l {
|
||||
let pts = samples.iter().map(|(_, q)| *q);
|
||||
if *forward { poly.extend(pts) } else { poly.extend(pts.rev()) }
|
||||
}
|
||||
// Closed: a loop whose pcurves leave a side of the box out (a sphere's poles, as
|
||||
// vertex loops) has that side as its closing edge.
|
||||
if let Some(first) = poly.first().copied() {
|
||||
poly.push(first);
|
||||
}
|
||||
poly
|
||||
})
|
||||
.collect();
|
||||
Ok(FaceTrims { surface, eval, domain: f.domain, loops, polygons })
|
||||
}
|
||||
|
||||
/// The face's own extent in (u, v): the box around its loops, within the surface's box.
|
||||
fn extent(&self) -> [[f64; 2]; 2] {
|
||||
let mut b = [[f64::INFINITY, f64::NEG_INFINITY]; 2];
|
||||
for q in self.polygons.iter().flatten() {
|
||||
for (k, x) in q.iter().enumerate() {
|
||||
b[k] = [b[k][0].min(*x), b[k][1].max(*x)];
|
||||
}
|
||||
}
|
||||
let [[u0, u1], [v0, v1]] = self.domain;
|
||||
[[b[0][0].max(u0), b[0][1].min(u1)], [b[1][0].max(v0), b[1][1].min(v1)]]
|
||||
}
|
||||
|
||||
/// Whether `q` is inside the face: inside its outer loop and outside its holes.
|
||||
fn inside(&self, q: [f64; 2]) -> bool {
|
||||
self.polygons.iter().filter(|l| crosses_right(q, l)).count() % 2 == 1
|
||||
}
|
||||
|
||||
/// The iso line of the surface at `fixed` (v when `along_u`, else u) across the face's box,
|
||||
/// cut to the face: the pieces inside it, each as its exact record curve on its interval, in
|
||||
/// the order the line runs (none when the line misses the face).
|
||||
fn clip(&self, along_u: bool, fixed: f64) -> Result<Vec<(brep::Curve, [f64; 2])>> {
|
||||
let [[u0, u1], [v0, v1]] = self.domain;
|
||||
let (lo, hi) = if along_u { (u0, u1) } else { (v0, v1) };
|
||||
let at_t = |t: f64| if along_u { [t, fixed] } else { [fixed, t] };
|
||||
// Where the line crosses the loops' pcurves (along it), each crossing solved on the
|
||||
// pcurve itself, with the box's ends.
|
||||
let across = |q: [f64; 2]| if along_u { q[1] - fixed } else { q[0] - fixed };
|
||||
let along = |q: [f64; 2]| if along_u { q[0] } else { q[1] };
|
||||
let mut cuts = vec![lo, hi];
|
||||
for (c, samples, _) in self.loops.iter().flatten() {
|
||||
for w in samples.windows(2) {
|
||||
let ((ta, qa), (tb, qb)) = (w[0], w[1]);
|
||||
let (ka, kb) = (across(qa), across(qb));
|
||||
if (ka < 0.0) == (kb < 0.0) || ka == kb {
|
||||
continue;
|
||||
}
|
||||
let (mut x, mut y) = (ta, tb);
|
||||
for _ in 0..80 {
|
||||
let m = 0.5 * (x + y);
|
||||
match c.eval(m).map(across) {
|
||||
Some(k) if (k < 0.0) == (ka < 0.0) => x = m,
|
||||
Some(_) => y = m,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if let Some(t) = c.eval(0.5 * (x + y)).map(along).filter(|t| *t > lo && *t < hi) {
|
||||
cuts.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
cuts.sort_by(f64::total_cmp);
|
||||
cuts.dedup_by(|a, b| (*a - *b).abs() <= 1e-9 * (1.0 + b.abs()));
|
||||
let mut out = Vec::new();
|
||||
// Neighbouring inside pieces are one piece.
|
||||
let mut run: Option<(f64, f64)> = None;
|
||||
for w in cuts.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
if !(b - a > 1e-12) {
|
||||
continue;
|
||||
}
|
||||
if self.inside(at_t(0.5 * (a + b))) {
|
||||
run = Some(run.map_or((a, b), |(s, _)| (s, b)));
|
||||
} else if let Some(r) = run.take() {
|
||||
out.push(iso_record(self.surface, along_u, fixed, r)?);
|
||||
}
|
||||
}
|
||||
if let Some(r) = run {
|
||||
out.push(iso_record(self.surface, along_u, fixed, r)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// The isoparametric curve of face `face` of `body` through the point `at` on it, the pieces of
|
||||
/// it inside the face, each as its exact record curve on its interval: along the surface's first
|
||||
/// parameter (`along_u`; around a cylinder, cone, sphere or torus) or its second (along them).
|
||||
/// On a B-spline surface the curve is the surface's row at the point's other parameter (each
|
||||
/// control point the column through it evaluated there, exactly); on an analytic surface its
|
||||
/// line or circle. The pieces are where the curve crosses the face's loops (their pcurves).
|
||||
pub fn iso_curves(body: &Body, face: usize, at: Vec3, along_u: bool) -> Result<Vec<(brep::Curve, [f64; 2])>> {
|
||||
let trims = FaceTrims::new(body, face)?;
|
||||
let [[u0, u1], [v0, v1]] = trims.domain;
|
||||
let size = body.size().max(1e-9);
|
||||
let mid = [0.5 * (u0 + u1), 0.5 * (v0 + v1)];
|
||||
let [mut pu, mut pv] = trims
|
||||
.eval
|
||||
.project(at, Some(mid), size * 1e-3 + 1e-6)
|
||||
.ok_or_else(|| KernelError::Invalid("iso curve: the point is not on the face".into()))?;
|
||||
// Into the face's box, by whole periods.
|
||||
let period = trims.eval.domain().period;
|
||||
for (x, (lo, hi), p) in [(&mut pu, (u0, u1), period[0]), (&mut pv, (v0, v1), period[1])] {
|
||||
if let Some(p) = p.filter(|p| *p > 0.0) {
|
||||
*x = lo + (*x - lo).rem_euclid(p);
|
||||
if *x > hi + 1e-12 {
|
||||
*x -= p;
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = trims.clip(along_u, if along_u { pv } else { pu })?;
|
||||
if out.is_empty() {
|
||||
return Err(KernelError::Invalid("the iso curve runs outside the face".into()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The isoparametric curves of face `face` of `body` for Isocurve Analysis: `count` each way
|
||||
/// (1…[`MAX_ISO_COUNT`]), the held parameter evenly spaced across the face's extent in (u, v)
|
||||
/// with the extent's sides left out (`k / (count + 1)` of the way, k = 1…count), each cut to the
|
||||
/// face ([`FaceTrims::clip`]) and its points at chord tolerance `chord`. Along u first, then v.
|
||||
pub fn iso_grid(body: &Body, face: usize, count: usize, chord: f64) -> Result<Vec<IsoLine>> {
|
||||
if !(1..=MAX_ISO_COUNT).contains(&count) {
|
||||
return Err(KernelError::Invalid(format!("iso curves: 1…{MAX_ISO_COUNT} each way")));
|
||||
}
|
||||
if !(chord.is_finite() && chord > 0.0) {
|
||||
return Err(KernelError::Invalid("iso curves: the chord tolerance must be positive".into()));
|
||||
}
|
||||
let trims = FaceTrims::new(body, face)?;
|
||||
let [[u0, u1], [v0, v1]] = trims.extent();
|
||||
let mut out = Vec::new();
|
||||
for along_u in [true, false] {
|
||||
let (lo, hi) = if along_u { (v0, v1) } else { (u0, u1) };
|
||||
if !(hi > lo) {
|
||||
continue;
|
||||
}
|
||||
for k in 1..=count {
|
||||
let fixed = lo + (hi - lo) * k as f64 / (count + 1) as f64;
|
||||
for (curve, interval) in trims.clip(along_u, fixed)? {
|
||||
let segs = curve_path(&curve, interval)?;
|
||||
let mut points: Vec<Vec3> = segs.first().map(crate::PathSeg::start).into_iter().collect();
|
||||
for s in &segs {
|
||||
points.extend(s.points_after_start(chord));
|
||||
}
|
||||
out.push(IsoLine { along_u, fixed, curve, interval, points });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A pcurve's parameters and points at [`ISO_SAMPLES`] per knot span, in its direction.
|
||||
fn pcurve_samples(c: &brep::Curve2) -> Vec<(f64, [f64; 2])> {
|
||||
let Some([a, b]) = c.interval() else { return Vec::new() };
|
||||
let spans = match c {
|
||||
brep::Curve2::Spline { knots, .. } | brep::Curve2::RationalSpline { knots, .. } => knots.windows(2).filter(|w| w[1] > w[0]).count().max(1),
|
||||
};
|
||||
let n = (spans * ISO_SAMPLES).min(1 << 16);
|
||||
(0..=n)
|
||||
.filter_map(|i| {
|
||||
let t = a + (b - a) * i as f64 / n as f64;
|
||||
c.eval(t).map(|q| (t, q))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the ray from `q` along +u crosses the closed polygon `l` an odd number of times.
|
||||
fn crosses_right(q: [f64; 2], l: &[[f64; 2]]) -> bool {
|
||||
let mut odd = false;
|
||||
for w in l.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
if (a[1] > q[1]) != (b[1] > q[1]) && q[0] < a[0] + (b[0] - a[0]) * (q[1] - a[1]) / (b[1] - a[1]) {
|
||||
odd = !odd;
|
||||
}
|
||||
}
|
||||
odd
|
||||
}
|
||||
|
||||
/// The iso curve of `surface` at `fixed` (the other parameter) as a record curve on `[a, b]` of
|
||||
/// its own parameter: the surface's own line or circle, or its B-spline row.
|
||||
fn iso_record(surface: &brep::Surface, along_u: bool, fixed: f64, (a, b): (f64, f64)) -> Result<(brep::Curve, [f64; 2])> {
|
||||
use solidcraft_geom::brep::{Curve, Frame, Surface};
|
||||
let v = |q: [f64; 3]| Vec3::new(q[0], q[1], q[2]);
|
||||
let d = |q: Vec3| [q.x, q.y, q.z];
|
||||
let line = |p: Vec3, q: Vec3| (Curve::Line { start: d(p), end: d(q) }, [0.0, 1.0]);
|
||||
let circle = |o: Vec3, x: Vec3, y: Vec3, r: f64| (Curve::Circle { frame: Frame { origin: d(o), x_axis: d(x), y_axis: d(y) }, radius: r }, [a, b]);
|
||||
let frame = |f: &Frame| (v(f.origin), v(f.x_axis), v(f.y_axis), v(f.x_axis).cross(v(f.y_axis)));
|
||||
Ok(match surface {
|
||||
Surface::Plane { origin, u_axis, v_axis } => {
|
||||
let (o, x, y) = (v(*origin), v(*u_axis), v(*v_axis));
|
||||
let at = |t: f64| if along_u { o + x * t + y * fixed } else { o + x * fixed + y * t };
|
||||
line(at(a), at(b))
|
||||
}
|
||||
Surface::PlaneThrough { origin, u_point, v_point } => {
|
||||
let (o, x, y) = (v(*origin), v(*u_point) - v(*origin), v(*v_point) - v(*origin));
|
||||
let at = |t: f64| if along_u { o + x * t + y * fixed } else { o + x * fixed + y * t };
|
||||
line(at(a), at(b))
|
||||
}
|
||||
Surface::Cylinder { frame: f, radius } => {
|
||||
let (o, x, y, z) = frame(f);
|
||||
if along_u {
|
||||
circle(o + z * fixed, x, y, *radius)
|
||||
} else {
|
||||
let r = (x * fixed.cos() + y * fixed.sin()) * *radius;
|
||||
line(o + r + z * a, o + r + z * b)
|
||||
}
|
||||
}
|
||||
Surface::Cone { frame: f, radius_at_origin, half_angle } => {
|
||||
let (o, x, y, z) = frame(f);
|
||||
let r = |h: f64| radius_at_origin + h * half_angle.tan();
|
||||
if along_u {
|
||||
circle(o + z * fixed, x, y, r(fixed))
|
||||
} else {
|
||||
let dir = x * fixed.cos() + y * fixed.sin();
|
||||
line(o + dir * r(a) + z * a, o + dir * r(b) + z * b)
|
||||
}
|
||||
}
|
||||
Surface::Sphere { frame: f, radius } => {
|
||||
let (o, x, y, z) = frame(f);
|
||||
if along_u {
|
||||
circle(o + z * (radius * fixed.sin()), x, y, radius * fixed.cos())
|
||||
} else {
|
||||
circle(o, x * fixed.cos() + y * fixed.sin(), z, *radius)
|
||||
}
|
||||
}
|
||||
Surface::Torus { frame: f, major_radius, minor_radius } => {
|
||||
let (o, x, y, z) = frame(f);
|
||||
if along_u {
|
||||
circle(o + z * (minor_radius * fixed.sin()), x, y, major_radius + minor_radius * fixed.cos())
|
||||
} else {
|
||||
let dir = x * fixed.cos() + y * fixed.sin();
|
||||
circle(o + dir * *major_radius, dir, z, *minor_radius)
|
||||
}
|
||||
}
|
||||
Surface::Spline { u_knots, v_knots, points } => {
|
||||
let rows: Vec<Vec<[f64; 4]>> = points.iter().map(|r| r.iter().map(|q| [q[0], q[1], q[2], 1.0]).collect()).collect();
|
||||
spline_iso(u_knots, v_knots, &rows, along_u, fixed, a, b)?
|
||||
}
|
||||
Surface::RationalSpline { u_knots, v_knots, homogeneous } => spline_iso(u_knots, v_knots, homogeneous, along_u, fixed, a, b)?,
|
||||
Surface::PlacedRevolution { .. } => {
|
||||
return Err(KernelError::Failed("not supported yet: an iso curve of a revolved surface record".into()));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The row of a B-spline surface (homogeneous `net[i][j]`, i along u) at `fixed`: along u, each
|
||||
/// control point the v-column through it evaluated at `fixed` (along v, the u-row), on `[a, b]`.
|
||||
fn spline_iso(u_knots: &[f64], v_knots: &[f64], net: &[Vec<[f64; 4]>], along_u: bool, fixed: f64, a: f64, b: f64) -> Result<(brep::Curve, [f64; 2])> {
|
||||
use solidcraft_geom::bspline::CurveN;
|
||||
let bad = || KernelError::Invalid("iso curve: the surface's B-spline does not evaluate".into());
|
||||
let (nu, nv) = (net.len(), net.first().map_or(0, Vec::len));
|
||||
if nu * nv > MAX_CURVE_POINTS || net.iter().any(|r| r.len() != nv) {
|
||||
return Err(bad());
|
||||
}
|
||||
let (pu, pv) = (u_knots.len().checked_sub(nu + 1).ok_or_else(bad)?, v_knots.len().checked_sub(nv + 1).ok_or_else(bad)?);
|
||||
let at = |degree: usize, knots: &[f64], ctrl: Vec<[f64; 4]>| CurveN::<4> { degree, knots: knots.to_vec(), ctrl }.eval(fixed).map(|(p, _)| p);
|
||||
let (knots, homogeneous): (Vec<f64>, Vec<[f64; 4]>) = if along_u {
|
||||
(u_knots.to_vec(), net.iter().map(|row| at(pv, v_knots, row.clone())).collect::<Option<_>>().ok_or_else(bad)?)
|
||||
} else {
|
||||
(
|
||||
v_knots.to_vec(),
|
||||
(0..nv).map(|j| at(pu, u_knots, net.iter().filter_map(|r| r.get(j).copied()).collect())).collect::<Option<_>>().ok_or_else(bad)?,
|
||||
)
|
||||
};
|
||||
Ok((brep::Curve::RationalSpline { knots, homogeneous }, [a, b]))
|
||||
}
|
||||
@@ -51,6 +51,7 @@ mod iges_out;
|
||||
mod intersection;
|
||||
#[cfg(test)]
|
||||
mod intersection_curve_tests;
|
||||
mod iso;
|
||||
#[cfg(test)]
|
||||
mod issue_1038_tests;
|
||||
#[cfg(test)]
|
||||
@@ -183,6 +184,7 @@ pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh, sweep_helix_radial
|
||||
pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
|
||||
pub use iges_out::iges_export_bodies;
|
||||
pub use intersection::{MeetingEdge, meeting_edges};
|
||||
pub use iso::{IsoLine, MAX_ISO_COUNT, iso_curves, iso_grid};
|
||||
pub use lip::{raise_band, rim_band};
|
||||
pub use loft_blend::loft_blend;
|
||||
pub use matchsurf::{match_surface, seam_continuity};
|
||||
@@ -194,8 +196,7 @@ pub use native::{brep_record, decode_native_body, encode_native_body, native_pre
|
||||
pub use offset::with_same_surface;
|
||||
pub use ops::{BoolOp, boolean, half_space, join_apart, place_matrix, split_by_plane, transform, transform_matrix, union_all, union_apart};
|
||||
pub use point_path::{
|
||||
PathPiece, PointPath, SpaceBezier, curve_path, edge_path, face_section_along, iso_curves, project_onto_face, sketch_record_curve,
|
||||
sweep_point_path_with,
|
||||
PathPiece, PointPath, SpaceBezier, curve_path, edge_path, face_section_along, project_onto_face, sketch_record_curve, sweep_point_path_with,
|
||||
};
|
||||
pub use polybool::faceted_boolean;
|
||||
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
|
||||
|
||||
@@ -192,219 +192,8 @@ pub fn face_section_along(body: &Body, face: usize, at: Vec3, along: Vec3) -> Re
|
||||
crate::smlib_sweeps::face_section(body, at, at, normal)
|
||||
}
|
||||
|
||||
/// Samples per knot span (and at least this many per pcurve) where the iso curves look for the
|
||||
/// crossings of a face's loops and classify the face's inside.
|
||||
const ISO_SAMPLES: usize = 64;
|
||||
|
||||
/// The isoparametric curve of face `face` of `body` through the point `at` on it, the pieces of
|
||||
/// it inside the face, each as its exact record curve on its interval: along the surface's first
|
||||
/// parameter (`along_u`; around a cylinder, cone, sphere or torus) or its second (along them).
|
||||
/// On a B-spline surface the curve is the surface's row at the point's other parameter (each
|
||||
/// control point the column through it evaluated there, exactly); on an analytic surface its
|
||||
/// line or circle. The pieces are where the curve crosses the face's loops (their pcurves).
|
||||
pub fn iso_curves(body: &Body, face: usize, at: Vec3, along_u: bool) -> Result<Vec<(brep::Curve, [f64; 2])>> {
|
||||
use solidcraft_geom::brep::eval::SurfaceEval;
|
||||
let record = body.record();
|
||||
let f = record.faces.get(face).ok_or_else(|| KernelError::Invalid(format!("the body has no face {face}")))?;
|
||||
let eval = SurfaceEval::new(f.parameters()).map_err(|e| KernelError::Failed(format!("iso curve: {e}")))?;
|
||||
let [[u0, u1], [v0, v1]] = f.domain;
|
||||
let size = body.size().max(1e-9);
|
||||
let mid = [0.5 * (u0 + u1), 0.5 * (v0 + v1)];
|
||||
let [mut pu, mut pv] =
|
||||
eval.project(at, Some(mid), size * 1e-3 + 1e-6).ok_or_else(|| KernelError::Invalid("iso curve: the point is not on the face".into()))?;
|
||||
// Into the face's box, by whole periods.
|
||||
let period = eval.domain().period;
|
||||
for (x, (lo, hi), p) in [(&mut pu, (u0, u1), period[0]), (&mut pv, (v0, v1), period[1])] {
|
||||
if let Some(p) = p.filter(|p| *p > 0.0) {
|
||||
*x = lo + (*x - lo).rem_euclid(p);
|
||||
if *x > hi + 1e-12 {
|
||||
*x -= p;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The iso line in (u, v): `fixed` held, the other running over the box.
|
||||
let (fixed, (lo, hi)) = if along_u { (pv, (u0, u1)) } else { (pu, (v0, v1)) };
|
||||
let at_t = |t: f64| if along_u { [t, fixed] } else { [fixed, t] };
|
||||
let pcurves: Vec<Vec<&brep::Curve2>> = f
|
||||
.loops
|
||||
.iter()
|
||||
.filter_map(|l| match l {
|
||||
brep::Loop::Edges { edges } => Some(edges.iter().map(|e| &e.pcurve).collect()),
|
||||
brep::Loop::Vertex { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
// Where the line crosses the loops' pcurves (along it), each crossing solved on the pcurve
|
||||
// itself, with the box's ends.
|
||||
let across = |q: [f64; 2]| if along_u { q[1] - fixed } else { q[0] - fixed };
|
||||
let along = |q: [f64; 2]| if along_u { q[0] } else { q[1] };
|
||||
let mut cuts = vec![lo, hi];
|
||||
for c in pcurves.iter().flatten() {
|
||||
let samples = pcurve_samples(c);
|
||||
for w in samples.windows(2) {
|
||||
let ((ta, qa), (tb, qb)) = (w[0], w[1]);
|
||||
let (ka, kb) = (across(qa), across(qb));
|
||||
if (ka < 0.0) == (kb < 0.0) || ka == kb {
|
||||
continue;
|
||||
}
|
||||
let (mut x, mut y) = (ta, tb);
|
||||
for _ in 0..80 {
|
||||
let m = 0.5 * (x + y);
|
||||
match c.eval(m).map(across) {
|
||||
Some(k) if (k < 0.0) == (ka < 0.0) => x = m,
|
||||
Some(_) => y = m,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if let Some(t) = c.eval(0.5 * (x + y)).map(along).filter(|t| *t > lo && *t < hi) {
|
||||
cuts.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
let loops: Vec<Vec<[f64; 2]>> = pcurves.iter().map(|l| l.iter().flat_map(|c| pcurve_samples(c)).map(|(_, q)| q).collect()).collect();
|
||||
cuts.sort_by(f64::total_cmp);
|
||||
cuts.dedup_by(|a, b| (*a - *b).abs() <= 1e-9 * (1.0 + b.abs()));
|
||||
let inside = |q: [f64; 2]| loops.iter().filter(|l| crosses_right(q, l)).count() % 2 == 1;
|
||||
let mut out = Vec::new();
|
||||
// Neighbouring inside pieces are one piece.
|
||||
let mut run: Option<(f64, f64)> = None;
|
||||
for w in cuts.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
if !(b - a > 1e-12) {
|
||||
continue;
|
||||
}
|
||||
if inside(at_t(0.5 * (a + b))) {
|
||||
run = Some(run.map_or((a, b), |(s, _)| (s, b)));
|
||||
} else if let Some(r) = run.take() {
|
||||
out.push(iso_record(f.parameters(), along_u, fixed, r)?);
|
||||
}
|
||||
}
|
||||
if let Some(r) = run {
|
||||
out.push(iso_record(f.parameters(), along_u, fixed, r)?);
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(KernelError::Invalid("the iso curve runs outside the face".into()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A pcurve's parameters and points at [`ISO_SAMPLES`] per knot span, in its direction.
|
||||
fn pcurve_samples(c: &brep::Curve2) -> Vec<(f64, [f64; 2])> {
|
||||
let Some([a, b]) = c.interval() else { return Vec::new() };
|
||||
let spans = match c {
|
||||
brep::Curve2::Spline { knots, .. } | brep::Curve2::RationalSpline { knots, .. } => knots.windows(2).filter(|w| w[1] > w[0]).count().max(1),
|
||||
};
|
||||
let n = (spans * ISO_SAMPLES).min(1 << 16);
|
||||
(0..=n)
|
||||
.filter_map(|i| {
|
||||
let t = a + (b - a) * i as f64 / n as f64;
|
||||
c.eval(t).map(|q| (t, q))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the ray from `q` along +u crosses the closed polyline `l` an odd number of times.
|
||||
fn crosses_right(q: [f64; 2], l: &[[f64; 2]]) -> bool {
|
||||
let mut odd = false;
|
||||
for w in l.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
if (a[1] > q[1]) != (b[1] > q[1]) && q[0] < a[0] + (b[0] - a[0]) * (q[1] - a[1]) / (b[1] - a[1]) {
|
||||
odd = !odd;
|
||||
}
|
||||
}
|
||||
odd
|
||||
}
|
||||
|
||||
/// The iso curve of `surface` at `fixed` (the other parameter) as a record curve on `[a, b]` of
|
||||
/// its own parameter: the surface's own line or circle, or its B-spline row.
|
||||
fn iso_record(surface: &brep::Surface, along_u: bool, fixed: f64, (a, b): (f64, f64)) -> Result<(brep::Curve, [f64; 2])> {
|
||||
use solidcraft_geom::brep::{Curve, Frame, Surface};
|
||||
let v = |q: [f64; 3]| Vec3::new(q[0], q[1], q[2]);
|
||||
let d = |q: Vec3| [q.x, q.y, q.z];
|
||||
let line = |p: Vec3, q: Vec3| (Curve::Line { start: d(p), end: d(q) }, [0.0, 1.0]);
|
||||
let circle = |o: Vec3, x: Vec3, y: Vec3, r: f64| (Curve::Circle { frame: Frame { origin: d(o), x_axis: d(x), y_axis: d(y) }, radius: r }, [a, b]);
|
||||
let frame = |f: &Frame| (v(f.origin), v(f.x_axis), v(f.y_axis), v(f.x_axis).cross(v(f.y_axis)));
|
||||
Ok(match surface {
|
||||
Surface::Plane { origin, u_axis, v_axis } => {
|
||||
let (o, x, y) = (v(*origin), v(*u_axis), v(*v_axis));
|
||||
let at = |t: f64| if along_u { o + x * t + y * fixed } else { o + x * fixed + y * t };
|
||||
line(at(a), at(b))
|
||||
}
|
||||
Surface::PlaneThrough { origin, u_point, v_point } => {
|
||||
let (o, x, y) = (v(*origin), v(*u_point) - v(*origin), v(*v_point) - v(*origin));
|
||||
let at = |t: f64| if along_u { o + x * t + y * fixed } else { o + x * fixed + y * t };
|
||||
line(at(a), at(b))
|
||||
}
|
||||
Surface::Cylinder { frame: f, radius } => {
|
||||
let (o, x, y, z) = frame(f);
|
||||
if along_u {
|
||||
circle(o + z * fixed, x, y, *radius)
|
||||
} else {
|
||||
let r = (x * fixed.cos() + y * fixed.sin()) * *radius;
|
||||
line(o + r + z * a, o + r + z * b)
|
||||
}
|
||||
}
|
||||
Surface::Cone { frame: f, radius_at_origin, half_angle } => {
|
||||
let (o, x, y, z) = frame(f);
|
||||
let r = |h: f64| radius_at_origin + h * half_angle.tan();
|
||||
if along_u {
|
||||
circle(o + z * fixed, x, y, r(fixed))
|
||||
} else {
|
||||
let dir = x * fixed.cos() + y * fixed.sin();
|
||||
line(o + dir * r(a) + z * a, o + dir * r(b) + z * b)
|
||||
}
|
||||
}
|
||||
Surface::Sphere { frame: f, radius } => {
|
||||
let (o, x, y, z) = frame(f);
|
||||
if along_u {
|
||||
circle(o + z * (radius * fixed.sin()), x, y, radius * fixed.cos())
|
||||
} else {
|
||||
circle(o, x * fixed.cos() + y * fixed.sin(), z, *radius)
|
||||
}
|
||||
}
|
||||
Surface::Torus { frame: f, major_radius, minor_radius } => {
|
||||
let (o, x, y, z) = frame(f);
|
||||
if along_u {
|
||||
circle(o + z * (minor_radius * fixed.sin()), x, y, major_radius + minor_radius * fixed.cos())
|
||||
} else {
|
||||
let dir = x * fixed.cos() + y * fixed.sin();
|
||||
circle(o + dir * *major_radius, dir, z, *minor_radius)
|
||||
}
|
||||
}
|
||||
Surface::Spline { u_knots, v_knots, points } => {
|
||||
let rows: Vec<Vec<[f64; 4]>> = points.iter().map(|r| r.iter().map(|q| [q[0], q[1], q[2], 1.0]).collect()).collect();
|
||||
spline_iso(u_knots, v_knots, &rows, along_u, fixed, a, b)?
|
||||
}
|
||||
Surface::RationalSpline { u_knots, v_knots, homogeneous } => spline_iso(u_knots, v_knots, homogeneous, along_u, fixed, a, b)?,
|
||||
Surface::PlacedRevolution { .. } => {
|
||||
return Err(KernelError::Failed("not supported yet: an iso curve of a revolved surface record".into()));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The row of a B-spline surface (homogeneous `net[i][j]`, i along u) at `fixed`: along u, each
|
||||
/// control point the v-column through it evaluated at `fixed` (along v, the u-row), on `[a, b]`.
|
||||
fn spline_iso(u_knots: &[f64], v_knots: &[f64], net: &[Vec<[f64; 4]>], along_u: bool, fixed: f64, a: f64, b: f64) -> Result<(brep::Curve, [f64; 2])> {
|
||||
use solidcraft_geom::bspline::CurveN;
|
||||
let bad = || KernelError::Invalid("iso curve: the surface's B-spline does not evaluate".into());
|
||||
let (nu, nv) = (net.len(), net.first().map_or(0, Vec::len));
|
||||
if nu * nv > MAX_CURVE_POINTS || net.iter().any(|r| r.len() != nv) {
|
||||
return Err(bad());
|
||||
}
|
||||
let (pu, pv) = (u_knots.len().checked_sub(nu + 1).ok_or_else(bad)?, v_knots.len().checked_sub(nv + 1).ok_or_else(bad)?);
|
||||
let at = |degree: usize, knots: &[f64], ctrl: Vec<[f64; 4]>| CurveN::<4> { degree, knots: knots.to_vec(), ctrl }.eval(fixed).map(|(p, _)| p);
|
||||
let (knots, homogeneous): (Vec<f64>, Vec<[f64; 4]>) = if along_u {
|
||||
(u_knots.to_vec(), net.iter().map(|row| at(pv, v_knots, row.clone())).collect::<Option<_>>().ok_or_else(bad)?)
|
||||
} else {
|
||||
(
|
||||
v_knots.to_vec(),
|
||||
(0..nv).map(|j| at(pu, u_knots, net.iter().filter_map(|r| r.get(j).copied()).collect())).collect::<Option<_>>().ok_or_else(bad)?,
|
||||
)
|
||||
};
|
||||
Ok((brep::Curve::RationalSpline { knots, homogeneous }, [a, b]))
|
||||
}
|
||||
|
||||
/// Most control points a record curve given to [`curve_path`] may have.
|
||||
const MAX_CURVE_POINTS: usize = 100_000;
|
||||
pub(crate) const MAX_CURVE_POINTS: usize = 100_000;
|
||||
|
||||
/// The record curve `c` on `[t0, t1]` as path segments ([`edge_path`]); the numbers are checked
|
||||
/// first (they may come from a design file).
|
||||
|
||||
@@ -82,8 +82,6 @@ thread_local! {
|
||||
static IMAGE: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||
/// Analysis markers shown while an analysis tool is active: (where, label).
|
||||
static MARKS: RefCell<Vec<(Vec3, String)>> = const { RefCell::new(Vec::new()) };
|
||||
/// Isocurve analysis polylines (as segments) while the tool is active.
|
||||
static ISO: RefCell<Vec<Vec<[Vec3; 2]>>> = const { RefCell::new(Vec::new()) };
|
||||
/// Center of Mass has just started: measure everything on the next frame.
|
||||
static DEFERRED: Cell<bool> = const { Cell::new(false) };
|
||||
/// A Trim drag: the pieces its path would remove (sketch polylines), and where the path's
|
||||
@@ -219,24 +217,6 @@ fn continuity_legend(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Isocurve analysis lines.
|
||||
fn iso_lines(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
if app.tool.as_ref().map(|t| t.cmd) != Some("inspect.isocurve") {
|
||||
ISO.with(|i| i.borrow_mut().clear());
|
||||
return;
|
||||
}
|
||||
let tk = crate::theme::Tokens::get();
|
||||
ISO.with(|i| {
|
||||
for c in i.borrow().iter() {
|
||||
for [a, b] in c {
|
||||
if let (Some(p), Some(q)) = (proj.to_screen(*a), proj.to_screen(*b)) {
|
||||
painter.line_segment([p, q], Stroke::new(1.2, tk.accent));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Analysis markers: a target with its label.
|
||||
fn marks(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
if !app.tool.as_ref().is_some_and(|t| matches!(t.cmd, "inspect.minimum_radius" | "inspect.center_of_mass" | "inspect.continuity")) {
|
||||
@@ -321,6 +301,13 @@ pub fn start_hook(app: &mut SolveApp, id: &str) -> bool {
|
||||
app.set_status(format!("Continuity: {} (click the button again to turn it off)", counts.join(", ")), false);
|
||||
return true;
|
||||
}
|
||||
if id == "inspect.isocurve" && !app.session.isocurves.is_empty() {
|
||||
// With curves shown, the button turns the analysis off; otherwise the tool picks faces.
|
||||
if app.run(id, json!({"clear": true})).is_ok() {
|
||||
app.set_status("Analysis off", false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if id == "inspect.center_of_mass" {
|
||||
// The whole model at once; the tool stays for picking single bodies.
|
||||
MARKS.with(|m| m.borrow_mut().clear());
|
||||
@@ -838,14 +825,10 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
}
|
||||
Mode::ModelRef if cmd == "inspect.isocurve" => {
|
||||
let face = hits.iter().find_map(|h| if let Hit::Face { point, .. } = h { Some(*point) } else { None });
|
||||
if let Some(at) = face
|
||||
&& let Ok(v) = app.run(cmd, json!({"faces": [[at.x, at.y, at.z]]}))
|
||||
{
|
||||
for c in v["curves"].as_array().into_iter().flatten() {
|
||||
let pts: Vec<Vec3> = c.as_array().into_iter().flatten().filter_map(v3).collect();
|
||||
let teeth: Vec<[Vec3; 2]> = pts.windows(2).map(|w| [w[0], w[1]]).collect();
|
||||
ISO.with(|i| i.borrow_mut().push(teeth));
|
||||
}
|
||||
// The face's curves join those shown (the viewport draws them, `Session::isocurves`); a
|
||||
// failure says why on the status line.
|
||||
if let Some(at) = face {
|
||||
let _ = app.run(cmd, json!({"faces": [[at.x, at.y, at.z]], "add": true}));
|
||||
}
|
||||
}
|
||||
Mode::ModelRef if cmd == "inspect.continuity" => {
|
||||
@@ -1525,7 +1508,6 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
|
||||
analysis(app, "inspect.center_of_mass", json!({}));
|
||||
}
|
||||
marks(app, painter, proj);
|
||||
iso_lines(app, painter, proj);
|
||||
continuity_legend(app, painter, proj);
|
||||
}
|
||||
|
||||
|
||||
@@ -460,6 +460,12 @@ fn scene_key(app: &SolveApp) -> u64 {
|
||||
format!("{:?}", seam.verdict).hash(&mut h);
|
||||
seam.points.first().map(|p| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]).hash(&mut h);
|
||||
}
|
||||
// So are the faces Isocurve Analysis shows (their curves follow the bodies, hashed above).
|
||||
for pick in &app.session.isocurves {
|
||||
pick.body.hash(&mut h);
|
||||
[pick.at.x, pick.at.y, pick.at.z].map(f64::to_bits).hash(&mut h);
|
||||
pick.count.hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
@@ -1420,6 +1426,19 @@ pub(crate) fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Isocurve Analysis: the isoparametric curves of each face it shows, on the faces (depth
|
||||
// tested, as edges are).
|
||||
let iso = s.isocurve_lines();
|
||||
if !iso.is_empty() {
|
||||
let col = sc.color(c4(tk.accent));
|
||||
for line in iso.iter().flat_map(|lines| lines.iter()) {
|
||||
for w in line.points.windows(2) {
|
||||
if let (Some(a), Some(b)) = (w.first(), w.get(1)) {
|
||||
sc.line(a.to_f32(), b.to_f32(), col, 1.4, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sc
|
||||
}
|
||||
@@ -5279,6 +5298,7 @@ pub(crate) fn scene_palette(dark: bool, mode: crate::theme::ColorVision) -> soli
|
||||
sketch_centerline: rgb(t.sketch_centerline),
|
||||
axes: t.axes().map(rgb),
|
||||
cap: rgb(t.section_cap),
|
||||
isocurve: rgb(t.accent),
|
||||
analysis: t.analysis,
|
||||
}
|
||||
}
|
||||
@@ -5920,6 +5940,41 @@ mod tests {
|
||||
assert_eq!((smooth, gap), (0, 0));
|
||||
}
|
||||
|
||||
/// Isocurve Analysis (#770): a face the tool clicks shows its isoparametric curves in the
|
||||
/// model scene, in the theme's accent and depth tested like edges (never drawn over the
|
||||
/// model); the button again turns them off.
|
||||
#[test]
|
||||
fn isocurves_are_drawn_on_the_faces_and_the_button_turns_them_off() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.cylinder", json!({"radius": 10, "height": 30})).unwrap();
|
||||
h.settle();
|
||||
// A line is two points, a colour and a width: 32 bytes.
|
||||
let accent = |bytes: &[u8], sc: &GpuScene| {
|
||||
bytes
|
||||
.as_chunks::<32>()
|
||||
.0
|
||||
.iter()
|
||||
.filter_map(|r| sc.palette.get(crate::gpu::PaletteIndex(u32::from_le_bytes(r.get(24..28)?.try_into().ok()?))))
|
||||
.filter(|c| *c == c4(Tokens::get().accent))
|
||||
.count()
|
||||
};
|
||||
let before = build_scene(&h.app);
|
||||
assert_eq!(accent(&before.lines, &before), 0);
|
||||
h.step(&json!({"start": "inspect.isocurve"})).unwrap();
|
||||
assert_eq!(h.app.tool.as_ref().map(|t| t.cmd), Some("inspect.isocurve"));
|
||||
h.step(&json!({"click": {"world": [10, 0, 15]}})).unwrap();
|
||||
assert_eq!(h.app.session.isocurves.len(), 1, "{:?}", h.app.status);
|
||||
let sc = build_scene(&h.app);
|
||||
let segments: usize = h.app.session.isocurve_lines().iter().flat_map(|l| l.iter()).map(|l| l.points.len() - 1).sum();
|
||||
assert!(segments > 16, "eight curves each way: {segments} segments");
|
||||
assert_eq!(accent(&sc.lines, &sc), segments, "every segment on the model, in the accent");
|
||||
assert_eq!(accent(&sc.overlay, &sc), 0, "none over the model");
|
||||
h.step(&json!({"start": "inspect.isocurve"})).unwrap();
|
||||
assert!(h.app.session.isocurves.is_empty() && h.app.tool.is_none());
|
||||
let after = build_scene(&h.app);
|
||||
assert_eq!(accent(&after.lines, &after), 0, "the curves go with the analysis");
|
||||
}
|
||||
|
||||
/// Bodies drawn from their display plans, evaluated on the GPU, look like the same bodies
|
||||
/// built into triangles on the CPU: the sample design rendered both ways through the
|
||||
/// viewport's real pipelines, offscreen, on this machine's GPU (skipped without one).
|
||||
|
||||
Reference in New Issue
Block a user