Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c650d1867f | ||
|
|
8c13914d6c | ||
|
|
f0292b1606 | ||
|
|
732447d2dc |
No files matched your search
@@ -138,6 +138,10 @@ press keys, then assert on the result.
|
||||
A new `.json` file in that folder is picked up automatically. To record a bug before it's fixed,
|
||||
put `{"pending": "why"}` in the scenario, and remove it in the PR that fixes the bug.
|
||||
|
||||
**A failing test without its fix can't merge as-is:** it would turn `main`'s CI red for everyone.
|
||||
Either include the fix in the same PR, or mark the test `#[ignore = "fails until #123 is fixed"]`
|
||||
(it can then merge right away), and remove that line in the PR that fixes the bug.
|
||||
|
||||
**Use the `ci` profile.** CI runs the tests with `--profile ci`: light optimisation, line tables,
|
||||
no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It
|
||||
builds several times faster than `--release`. If a test fails only in a plain debug build, say so
|
||||
|
||||
@@ -63,6 +63,9 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
|
||||
## Dimensioning and error reporting
|
||||
|
||||
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
|
||||
waits for a distinct point without a degenerate-shape error or history entry. Typed input
|
||||
and programmatic commands retain their validation.
|
||||
- Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints.
|
||||
Type a value and press Enter to commit one undo step; rejected values remain editable and
|
||||
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
|
||||
@@ -292,6 +295,9 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
continuous collision detection; local thin walls and small details remain limitations.
|
||||
See [contact-set limits and recovery](docs/contact-sets.md).
|
||||
|
||||
Feature dialog header chevrons collapse and restore the form without discarding selections
|
||||
or edited values, including the Appearance panel.
|
||||
|
||||
## Document-opening performance
|
||||
|
||||
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
|
||||
|
||||
@@ -21,6 +21,9 @@ mod fuzz;
|
||||
mod oracle;
|
||||
mod recipe;
|
||||
mod seams;
|
||||
// Geometry comparison helpers; not called from the CLI commands yet.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
mod spatial;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Geometry comparison helpers: an STL reader, bounding boxes, volume, centre of mass and principal
|
||||
//! second moments of a closed triangle mesh, and a sampled symmetric Hausdorff distance between two
|
||||
//! triangle meshes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A triangle soup in mm.
|
||||
pub type Tri = [[f64; 3]; 3];
|
||||
|
||||
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
|
||||
}
|
||||
fn scale(a: [f64; 3], s: f64) -> [f64; 3] {
|
||||
[a[0] * s, a[1] * s, a[2] * s]
|
||||
}
|
||||
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
/// Read a binary (or ASCII) STL file.
|
||||
pub fn read_stl(path: &str) -> Result<Vec<Tri>, String> {
|
||||
let b = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if b.len() >= 84 {
|
||||
let n = u32::from_le_bytes([b[80], b[81], b[82], b[83]]) as usize;
|
||||
if b.len() == 84 + 50 * n {
|
||||
let f = |o: usize| f64::from(f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]));
|
||||
return Ok((0..n)
|
||||
.map(|i| {
|
||||
let o = 84 + 50 * i + 12;
|
||||
let v = |k: usize| [f(o + 12 * k), f(o + 12 * k + 4), f(o + 12 * k + 8)];
|
||||
[v(0), v(1), v(2)]
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8_lossy(&b);
|
||||
let mut verts = Vec::new();
|
||||
for line in text.lines() {
|
||||
let mut it = line.split_whitespace();
|
||||
if it.next() == Some("vertex") {
|
||||
let c: Vec<f64> = it.filter_map(|x| x.parse().ok()).collect();
|
||||
if c.len() == 3 {
|
||||
verts.push([c[0], c[1], c[2]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if verts.is_empty() || verts.len() % 3 != 0 {
|
||||
return Err(format!("{path}: not an STL file"));
|
||||
}
|
||||
Ok(verts.chunks(3).map(|c| [c[0], c[1], c[2]]).collect())
|
||||
}
|
||||
|
||||
/// Volume, centre of mass and the principal second moments of volume (mm^5, sorted ascending) of a
|
||||
/// closed, outward-oriented triangle soup, by signed tetrahedra from the origin.
|
||||
pub fn mass_properties(tris: &[Tri]) -> (f64, [f64; 3], [f64; 3]) {
|
||||
let (mut v6, mut m1) = (0.0, [0.0; 3]);
|
||||
let mut c = [[0.0; 3]; 3]; // ∫ x xᵀ dV
|
||||
for t in tris {
|
||||
let d = dot(t[0], cross(t[1], t[2]));
|
||||
v6 += d;
|
||||
let s = add(add(t[0], t[1]), t[2]);
|
||||
m1 = add(m1, scale(s, d / 24.0));
|
||||
for (i, row) in c.iter_mut().enumerate() {
|
||||
for (j, cell) in row.iter_mut().enumerate() {
|
||||
let vv: f64 = t.iter().map(|p| p[i] * p[j]).sum::<f64>() + s[i] * s[j];
|
||||
*cell += d / 120.0 * vv;
|
||||
}
|
||||
}
|
||||
}
|
||||
let vol = v6 / 6.0;
|
||||
if vol.abs() < 1e-12 {
|
||||
return (0.0, [0.0; 3], [0.0; 3]);
|
||||
}
|
||||
let com = scale(m1, 1.0 / vol);
|
||||
// Central covariance and inertia tensor I = tr(C)·1 − C.
|
||||
let mut cc = c;
|
||||
for (i, row) in cc.iter_mut().enumerate() {
|
||||
for (j, cell) in row.iter_mut().enumerate() {
|
||||
*cell -= vol * com[i] * com[j];
|
||||
}
|
||||
}
|
||||
let tr = cc[0][0] + cc[1][1] + cc[2][2];
|
||||
let mut inertia = [[0.0; 3]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
inertia[i][j] = if i == j { tr - cc[i][j] } else { -cc[i][j] };
|
||||
}
|
||||
}
|
||||
let mut ev = eigen_sym3(inertia);
|
||||
ev.sort_by(f64::total_cmp);
|
||||
(vol, com, ev)
|
||||
}
|
||||
|
||||
/// Eigenvalues of a symmetric 3×3 matrix (Jacobi rotations).
|
||||
fn eigen_sym3(mut a: [[f64; 3]; 3]) -> [f64; 3] {
|
||||
for _ in 0..50 {
|
||||
let (mut p, mut q, mut big) = (0, 1, 0.0);
|
||||
for (i, j) in [(0, 1), (0, 2), (1, 2)] {
|
||||
if a[i][j].abs() > big {
|
||||
(p, q, big) = (i, j, a[i][j].abs());
|
||||
}
|
||||
}
|
||||
if big <= 1e-15 * (a[0][0].abs() + a[1][1].abs() + a[2][2].abs()).max(1e-300) {
|
||||
break;
|
||||
}
|
||||
let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
|
||||
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
|
||||
let t = if theta == 0.0 { 1.0 } else { t };
|
||||
let (cs, sn) = (1.0 / (t * t + 1.0).sqrt(), t / (t * t + 1.0).sqrt());
|
||||
let mut b = a;
|
||||
for k in 0..3 {
|
||||
b[k][p] = cs * a[k][p] - sn * a[k][q];
|
||||
b[k][q] = sn * a[k][p] + cs * a[k][q];
|
||||
}
|
||||
let mut c2 = b;
|
||||
for k in 0..3 {
|
||||
c2[p][k] = cs * b[p][k] - sn * b[q][k];
|
||||
c2[q][k] = sn * b[p][k] + cs * b[q][k];
|
||||
}
|
||||
a = c2;
|
||||
}
|
||||
[a[0][0], a[1][1], a[2][2]]
|
||||
}
|
||||
|
||||
/// Axis-aligned bounds of a triangle soup.
|
||||
pub fn bounds(tris: &[Tri]) -> ([f64; 3], [f64; 3]) {
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for p in tris.iter().flatten() {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(p[k]);
|
||||
hi[k] = hi[k].max(p[k]);
|
||||
}
|
||||
}
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
/// Distance from a point to a triangle (Ericson, Real-Time Collision Detection 5.1.5).
|
||||
fn point_tri(p: [f64; 3], t: &Tri) -> f64 {
|
||||
let (a, b, c) = (t[0], t[1], t[2]);
|
||||
let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a));
|
||||
let (d1, d2) = (dot(ab, ap), dot(ac, ap));
|
||||
let q = if d1 <= 0.0 && d2 <= 0.0 {
|
||||
a
|
||||
} else {
|
||||
let bp = sub(p, b);
|
||||
let (d3, d4) = (dot(ab, bp), dot(ac, bp));
|
||||
if d3 >= 0.0 && d4 <= d3 {
|
||||
b
|
||||
} else {
|
||||
let vc = d1 * d4 - d3 * d2;
|
||||
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
|
||||
add(a, scale(ab, d1 / (d1 - d3)))
|
||||
} else {
|
||||
let cp = sub(p, c);
|
||||
let (d5, d6) = (dot(ab, cp), dot(ac, cp));
|
||||
if d6 >= 0.0 && d5 <= d6 {
|
||||
c
|
||||
} else {
|
||||
let vb = d5 * d2 - d1 * d6;
|
||||
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
|
||||
add(a, scale(ac, d2 / (d2 - d6)))
|
||||
} else {
|
||||
let va = d3 * d6 - d5 * d4;
|
||||
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
|
||||
add(b, scale(sub(c, b), (d4 - d3) / ((d4 - d3) + (d5 - d6))))
|
||||
} else {
|
||||
let den = va + vb + vc;
|
||||
if den.abs() < 1e-300 { a } else { add(a, add(scale(ab, vb / den), scale(ac, vc / den))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let d = sub(p, q);
|
||||
dot(d, d).sqrt()
|
||||
}
|
||||
|
||||
/// Uniform grid of triangles for distance queries up to `cap`.
|
||||
struct Grid<'a> {
|
||||
tris: &'a [Tri],
|
||||
cell: f64,
|
||||
cells: HashMap<(i64, i64, i64), Vec<u32>>,
|
||||
}
|
||||
|
||||
impl<'a> Grid<'a> {
|
||||
fn new(tris: &'a [Tri], cell: f64) -> Self {
|
||||
let mut cells: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
|
||||
let key = |x: f64| (x / cell).floor() as i64;
|
||||
for (i, t) in tris.iter().enumerate() {
|
||||
let (lo, hi) = bounds(std::slice::from_ref(t));
|
||||
for x in key(lo[0])..=key(hi[0]) {
|
||||
for y in key(lo[1])..=key(hi[1]) {
|
||||
for z in key(lo[2])..=key(hi[2]) {
|
||||
cells.entry((x, y, z)).or_default().push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Grid { tris, cell, cells }
|
||||
}
|
||||
|
||||
/// Distance from `p` to the nearest triangle, or `cap` if none is that close. Searches shells
|
||||
/// of cells outwards and stops once the nearest hit is closer than the unsearched shells.
|
||||
fn distance(&self, p: [f64; 3], cap: f64, seen: &mut [u32], stamp: u32) -> f64 {
|
||||
let rmax = (cap / self.cell).ceil() as i64;
|
||||
let k = |x: f64| (x / self.cell).floor() as i64;
|
||||
let (cx, cy, cz) = (k(p[0]), k(p[1]), k(p[2]));
|
||||
let mut best = cap;
|
||||
for r in 0..=rmax {
|
||||
for x in cx - r..=cx + r {
|
||||
for y in cy - r..=cy + r {
|
||||
for z in cz - r..=cz + r {
|
||||
// only the shell at Chebyshev distance r
|
||||
if (x - cx).abs() != r && (y - cy).abs() != r && (z - cz).abs() != r {
|
||||
continue;
|
||||
}
|
||||
let Some(list) = self.cells.get(&(x, y, z)) else { continue };
|
||||
for &i in list {
|
||||
let Some(slot) = seen.get_mut(i as usize) else { continue };
|
||||
if *slot == stamp {
|
||||
continue;
|
||||
}
|
||||
*slot = stamp;
|
||||
if let Some(t) = self.tris.get(i as usize) {
|
||||
best = best.min(point_tri(p, t));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Everything not yet searched is at least r·cell away from p.
|
||||
if best <= r as f64 * self.cell {
|
||||
break;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
/// At most this many sample points per side (larger meshes are sampled with an even stride).
|
||||
const MAX_SAMPLES: usize = 250_000;
|
||||
|
||||
/// Sample points of a triangle soup: its vertices and triangle centroids (every k-th triangle for
|
||||
/// very large meshes).
|
||||
fn samples(tris: &[Tri]) -> Vec<[f64; 3]> {
|
||||
let stride = (tris.len() * 4).div_ceil(MAX_SAMPLES).max(1);
|
||||
let mut out: Vec<[f64; 3]> = Vec::with_capacity(tris.len() * 4 / stride + 4);
|
||||
for t in tris.iter().step_by(stride) {
|
||||
out.extend_from_slice(t);
|
||||
out.push(scale(add(add(t[0], t[1]), t[2]), 1.0 / 3.0));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One-sided sampled distance from `from` to `to`: (max, mean, worst sample point). Distances are
|
||||
/// capped at `cap`.
|
||||
fn one_sided(from: &[Tri], to: &[Tri], cap: f64) -> (f64, f64, [f64; 3]) {
|
||||
let (lo, hi) = bounds(to);
|
||||
let diag = dot(sub(hi, lo), sub(hi, lo)).sqrt();
|
||||
// Cells about twice the typical triangle size (so a cell holds a handful of triangles), but no
|
||||
// finer than diag/400 (so big flat triangles don't fill too many cells).
|
||||
let area: f64 = to.iter().map(|t| dot(cross(sub(t[1], t[0]), sub(t[2], t[0])), cross(sub(t[1], t[0]), sub(t[2], t[0]))).sqrt() / 2.0).sum();
|
||||
let typical = (area / to.len().max(1) as f64).sqrt();
|
||||
let grid = Grid::new(to, (2.0 * typical).max(diag / 400.0).max(1e-3));
|
||||
let mut seen = vec![0u32; to.len()];
|
||||
let (mut max, mut sum, mut worst, mut n) = (0.0f64, 0.0, [0.0; 3], 0usize);
|
||||
for (i, p) in samples(from).into_iter().enumerate() {
|
||||
let d = grid.distance(p, cap, &mut seen, i as u32 + 1);
|
||||
sum += d;
|
||||
n += 1;
|
||||
if d > max {
|
||||
max = d;
|
||||
worst = p;
|
||||
}
|
||||
}
|
||||
(max, if n > 0 { sum / n as f64 } else { 0.0 }, worst)
|
||||
}
|
||||
|
||||
/// Sampled symmetric Hausdorff distance between two triangle soups (distances capped at `cap`).
|
||||
pub struct Hausdorff {
|
||||
pub a_to_b: f64,
|
||||
pub b_to_a: f64,
|
||||
pub mean: f64,
|
||||
/// Where the larger one-sided distance was found (mm, world).
|
||||
pub worst_at: [f64; 3],
|
||||
}
|
||||
|
||||
pub fn hausdorff(a_mesh: &[Tri], b_mesh: &[Tri], cap: f64) -> Hausdorff {
|
||||
let (a, am, ap) = one_sided(a_mesh, b_mesh, cap);
|
||||
let (b, bm, bp) = one_sided(b_mesh, a_mesh, cap);
|
||||
Hausdorff { a_to_b: a, b_to_a: b, mean: (am + bm) / 2.0, worst_at: if a >= b { ap } else { bp } }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cube(s: f64, o: [f64; 3]) -> Vec<Tri> {
|
||||
let v = |x: f64, y: f64, z: f64| [o[0] + x * s, o[1] + y * s, o[2] + z * s];
|
||||
let q = |a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]| vec![[a, b, c], [a, c, d]];
|
||||
let mut t = Vec::new();
|
||||
t.extend(q(v(0., 0., 0.), v(0., 1., 0.), v(1., 1., 0.), v(1., 0., 0.)));
|
||||
t.extend(q(v(0., 0., 1.), v(1., 0., 1.), v(1., 1., 1.), v(0., 1., 1.)));
|
||||
t.extend(q(v(0., 0., 0.), v(1., 0., 0.), v(1., 0., 1.), v(0., 0., 1.)));
|
||||
t.extend(q(v(0., 1., 0.), v(0., 1., 1.), v(1., 1., 1.), v(1., 1., 0.)));
|
||||
t.extend(q(v(0., 0., 0.), v(0., 0., 1.), v(0., 1., 1.), v(0., 1., 0.)));
|
||||
t.extend(q(v(1., 0., 0.), v(1., 1., 0.), v(1., 1., 1.), v(1., 0., 1.)));
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_moments_and_distance() {
|
||||
let c = cube(10.0, [5.0, -3.0, 2.0]);
|
||||
let (v, com, pm) = mass_properties(&c);
|
||||
assert!((v - 1000.0).abs() < 1e-9 && (com[0] - 10.0).abs() < 1e-9 && (com[2] - 7.0).abs() < 1e-9);
|
||||
// V·(a²+b²)/12 for a cube of side 10: 1000·200/12.
|
||||
for m in pm {
|
||||
assert!((m - 1000.0 * 200.0 / 12.0).abs() < 1e-6, "{pm:?}");
|
||||
}
|
||||
let moved = cube(10.0, [5.0, -3.0, 2.3]);
|
||||
let h = hausdorff(&c, &moved, 1.0);
|
||||
assert!((h.a_to_b - 0.3).abs() < 1e-9 && (h.b_to_a - 0.3).abs() < 1e-9);
|
||||
let same = hausdorff(&c, &c, 1.0);
|
||||
assert!(same.a_to_b < 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -1326,7 +1326,17 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
d.collapsed = !d.collapsed;
|
||||
}
|
||||
ui.label(RichText::new(heading.as_str()).strong().size(12.5));
|
||||
crate::frame::right_aligned(ui, total, 8.0, |ui| ui.label(RichText::new("»").color(t.text_dim)));
|
||||
let chevron_width = ui.spacing().interact_size.x;
|
||||
crate::frame::right_aligned(ui, total, chevron_width, |ui| {
|
||||
let chevron = if d.collapsed { "«" } else { "»" };
|
||||
if ui
|
||||
.add(egui::Button::new(RichText::new(chevron).color(t.text_dim)).frame(false).min_size(vec2(chevron_width, 0.0)))
|
||||
.on_hover_text(if d.collapsed { "Expand" } else { "Collapse" })
|
||||
.clicked()
|
||||
{
|
||||
d.collapsed = !d.collapsed;
|
||||
}
|
||||
});
|
||||
});
|
||||
if d.collapsed {
|
||||
return;
|
||||
|
||||
@@ -594,6 +594,27 @@ mod tests {
|
||||
assert_eq!(a.session.doc.appearances.bodies.get("Body1"), a.session.doc.appearances.bodies.get("Body2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_chevron_folds_and_restores_appearance_inputs() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
|
||||
h.app.start("appearance.assign");
|
||||
h.frames(3);
|
||||
let before = h.app.dialog.clone().unwrap();
|
||||
h.step(&json!({"widget":"»"})).unwrap();
|
||||
assert!(h.app.dialog.as_ref().unwrap().collapsed);
|
||||
assert!(h.widget("Appearance name").is_none());
|
||||
h.step(&json!({"widget":"«"})).unwrap();
|
||||
let after = h.app.dialog.as_ref().unwrap();
|
||||
assert!(!after.collapsed);
|
||||
assert_eq!(format!("{:?}", after.inputs), format!("{:?}", before.inputs));
|
||||
let (Kind::Appearance(before), Kind::Appearance(after)) = (&before.kind, &after.kind) else { panic!("appearance dialog") };
|
||||
assert_eq!(before.color, after.color);
|
||||
assert_eq!(before.opacity, after.opacity);
|
||||
assert_eq!(before.search, after.search);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_frames_and_unchanged_apply_keep_precise_opacity() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn repeated_first_point_is_neutral_and_drawing_can_continue() {
|
||||
for (command, rest) in [
|
||||
("sketch.rectangle.two_point", vec![[10, 5, 0]]),
|
||||
("sketch.rectangle.center", vec![[10, 5, 0]]),
|
||||
("sketch.rectangle.three_point", vec![[10, 0, 0], [10, 5, 0]]),
|
||||
("sketch.circle.center", vec![[5, 0, 0]]),
|
||||
("sketch.circle.three_point", vec![[10, 0, 0], [5, 5, 0]]),
|
||||
("sketch.line", vec![[10, 5, 0]]),
|
||||
("sketch.polygon.inscribed", vec![[10, 5, 0]]),
|
||||
("sketch.slot.center_to_center", vec![[10, 0, 0], [10, 3, 0]]),
|
||||
] {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.cam.distance = 50.0;
|
||||
h.step(&json!({"start":command})).unwrap();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let p = h.call("ui.at", json!({"sketch":[0,0]}))["result"].clone();
|
||||
h.call("ui.click", json!({"x":p[0],"y":p[1],"double":true}));
|
||||
assert!(!h.app.status.as_ref().is_some_and(|s| s.2), "{command}: {:?}", h.app.status);
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().pts.len(), 1, "{command}");
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
for point in rest {
|
||||
h.step(&json!({"move":{"world":point}})).unwrap();
|
||||
if command == "sketch.slot.center_to_center" {
|
||||
// The live width editor is placed at the cursor; Enter accepts it.
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
} else {
|
||||
h.step(&json!({"click":{"world":point}})).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(!h.app.status.as_ref().is_some_and(|s| s.2), "{command}: {:?}", h.app.status);
|
||||
let state = h.app.session.world_state();
|
||||
assert!(!state.sketches[0].sketch.curves.is_empty(), "{command}: {:?}; {:?}", h.app.tool, h.app.status);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1, "{command}");
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before, "{command}");
|
||||
}
|
||||
}
|
||||
@@ -542,41 +542,29 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
} else if cmds.is_empty() {
|
||||
ui.label(egui::RichText::new("Not available yet").color(t.text_dim));
|
||||
}
|
||||
let row_w = dropdown_width(app, ui, &cmds);
|
||||
// Up and Down move the keyboard focus through the enabled rows (from none
|
||||
// focused: the first or the last), instead of egui's own spatial move.
|
||||
let (down, up) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp)));
|
||||
if down || up {
|
||||
let enabled: Vec<&str> = cmds.iter().filter(|c| c.info(&app.session).enabled).map(|c| c.id).collect();
|
||||
let focused = ui.ctx().memory(|m| m.focused());
|
||||
let at = enabled.iter().position(|id| focused == Some(dropdown_row_id(ui, id)));
|
||||
let n = enabled.len();
|
||||
let next = match (at, down) {
|
||||
(None, true) => Some(0),
|
||||
(None, false) => n.checked_sub(1),
|
||||
(Some(i), true) => Some((i + 1) % n.max(1)),
|
||||
(Some(i), false) => Some((i + n.max(1) - 1) % n.max(1)),
|
||||
};
|
||||
if let Some(id) = next.and_then(|i| enabled.get(i)) {
|
||||
ui.ctx().memory_mut(|m| {
|
||||
m.request_focus(dropdown_row_id(ui, id));
|
||||
m.move_focus(egui::FocusDirection::None);
|
||||
});
|
||||
}
|
||||
}
|
||||
for c in &cmds {
|
||||
match dropdown_row(app, ui, c, row_w) {
|
||||
RowAction::None => {}
|
||||
RowAction::Menu(at) => menu = Some((at, c.id)),
|
||||
RowAction::Start => {
|
||||
app.start(c.id);
|
||||
let info = c.info(&app.session);
|
||||
ui.horizontal(|ui| {
|
||||
let (ir, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
icons::paint(ui.painter(), ir, c.icon, t.icon, t.icon_fill, t.accent);
|
||||
let b = ui.add_enabled(
|
||||
info.enabled,
|
||||
egui::Button::new(app.tr_label(c.label)).frame(false).shortcut_text(c.shortcut.unwrap_or("")),
|
||||
);
|
||||
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), b.rect.center());
|
||||
if b.secondary_clicked() {
|
||||
menu = Some((b.interact_pointer_pos().unwrap_or(b.rect.left_bottom()), c.id));
|
||||
}
|
||||
if b.clicked() {
|
||||
let id = c.id;
|
||||
app.start(id);
|
||||
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
if (area.response.clicked_elsewhere() && !lresp.clicked()) || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
if area.response.clicked_elsewhere() && !lresp.clicked() {
|
||||
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
|
||||
}
|
||||
}
|
||||
@@ -620,72 +608,6 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// What a click on a panel drop-down row asks for.
|
||||
enum RowAction {
|
||||
None,
|
||||
Start,
|
||||
Menu(egui::Pos2),
|
||||
}
|
||||
|
||||
const ROW_H: f32 = 24.0;
|
||||
|
||||
/// The id of a drop-down row (for keyboard focus).
|
||||
fn dropdown_row_id(ui: &egui::Ui, id: &str) -> egui::Id {
|
||||
ui.id().with(("dropdown_row", id))
|
||||
}
|
||||
|
||||
/// How wide a panel drop-down's rows are: the widest label and shortcut, at least 200.
|
||||
fn dropdown_width(app: &SolveApp, ui: &egui::Ui, cmds: &[&'static solvecraft_engine::CommandSpec]) -> f32 {
|
||||
let t = Tokens::get();
|
||||
let font = FontId::proportional(13.0);
|
||||
cmds.iter()
|
||||
.map(|c| {
|
||||
let label = ui.painter().layout_no_wrap(app.tr_label(c.label).to_string(), font.clone(), t.text).size().x;
|
||||
let key = c.shortcut.map(|k| ui.painter().layout_no_wrap(k.to_string(), font.clone(), t.text).size().x + 24.0).unwrap_or(0.0);
|
||||
6.0 + 18.0 + 8.0 + label + key + 30.0
|
||||
})
|
||||
.fold(200.0, f32::max)
|
||||
}
|
||||
|
||||
/// One command row of a panel drop-down: icon, label and shortcut, with the hover background
|
||||
/// under the pointer or keyboard focus (like every menu).
|
||||
fn dropdown_row(app: &SolveApp, ui: &mut egui::Ui, c: &'static solvecraft_engine::CommandSpec, width: f32) -> RowAction {
|
||||
let t = Tokens::get();
|
||||
let info = c.info(&app.session);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover());
|
||||
let sense = if info.enabled { Sense::click() } else { Sense::hover() };
|
||||
let resp = ui.interact(rect, dropdown_row_id(ui, c.id), sense);
|
||||
let label = app.tr_label(c.label).to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled() && info.enabled, &label));
|
||||
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), rect.center());
|
||||
crate::scenario::publish_handle(&format!("dropdown:{}", c.id), rect.center());
|
||||
let hot = info.enabled && (resp.hovered() || resp.has_focus());
|
||||
let p = ui.painter();
|
||||
if hot {
|
||||
p.rect_filled(rect, 3.0, t.hover);
|
||||
crate::scenario::publish_handle(&format!("dropdown_hot:{}", c.id), rect.center());
|
||||
}
|
||||
let (ink, fill, acc, text) =
|
||||
if info.enabled { (t.icon, t.icon_fill, t.accent, t.text) } else { (t.border, t.panel_header, t.border, t.text_dim) };
|
||||
let ir = Rect::from_center_size(pos2(rect.left() + 6.0 + 9.0, rect.center().y), vec2(18.0, 18.0));
|
||||
icons::paint(p, ir, c.icon, ink, fill, acc);
|
||||
p.text(pos2(ir.right() + 8.0, rect.center().y), Align2::LEFT_CENTER, &label, FontId::proportional(13.0), text);
|
||||
if let Some(k) = c.shortcut {
|
||||
p.text(pos2(rect.right() - 8.0, rect.center().y), Align2::RIGHT_CENTER, k, FontId::proportional(13.0), t.text_dim);
|
||||
}
|
||||
let resp = match &info.disabled_reason {
|
||||
Some(why) if !info.enabled => resp.on_hover_text(why),
|
||||
_ => resp,
|
||||
};
|
||||
if resp.secondary_clicked() {
|
||||
return RowAction::Menu(resp.interact_pointer_pos().unwrap_or(rect.left_bottom()));
|
||||
}
|
||||
if resp.clicked() && info.enabled {
|
||||
return RowAction::Start;
|
||||
}
|
||||
RowAction::None
|
||||
}
|
||||
|
||||
/// A toolbar button being dragged (its command id).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ToolbarDrag(&'static str);
|
||||
|
||||
@@ -12,6 +12,10 @@ use crate::viewport::{Hit, Proj, pick, sketch_point_at};
|
||||
#[path = "sketch_display_preview_tests.rs"]
|
||||
mod display_preview_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_neutral_click_tests.rs"]
|
||||
mod neutral_click_tests;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// Click points; `n` points complete the shape (0 = chain until finished).
|
||||
@@ -144,12 +148,18 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
|
||||
return;
|
||||
}
|
||||
match tool.kind {
|
||||
Kind::Draw(_) => {
|
||||
Kind::Draw(n) => {
|
||||
if let Some(mut p) = sketch_point_at(app, proj, pos) {
|
||||
// Typed values win over the cursor; lines snap to horizontal/vertical.
|
||||
if tool.dims.iter().any(|b| b.locked) || (tool.cmd == "sketch.line" && p.1.is_none()) {
|
||||
p = (crate::sketch_dims::effective(app, &tool, p.0), None);
|
||||
}
|
||||
// A repeated initial point supplies no extent. Keep waiting rather than
|
||||
// attempting a degenerate shape; explicit typed values still validate.
|
||||
if n != 1 && tool.pts.len() == 1 && tool.pts.first().is_some_and(|first| first.0 == p.0) && !tool.dims.iter().any(|b| b.locked) {
|
||||
app.tool = Some(tool);
|
||||
return;
|
||||
}
|
||||
app.tool = Some(tool);
|
||||
place(app, p);
|
||||
return;
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
[
|
||||
{
|
||||
"note": "#288: a panel drop-down highlights the row under the pointer with the hover background, and the row with keyboard focus. Drop-down rows publish `dropdown:<command id>`, the highlighted one also `dropdown_hot:<command id>`."
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "panel:SOLID:CREATE"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown:solid.box", "dropdown:solid.sphere"],
|
||||
"hidden_handles": ["dropdown_hot:solid.box", "dropdown_hot:solid.sphere"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": {
|
||||
"handle": "dropdown:solid.box"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:solid.box"],
|
||||
"hidden_handles": ["dropdown_hot:solid.sphere", "dropdown_hot:solid.cylinder"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": {
|
||||
"handle": "dropdown:solid.sphere"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:solid.sphere"],
|
||||
"hidden_handles": ["dropdown_hot:solid.box"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": [700, 600]
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown:solid.box"],
|
||||
"hidden_handles": ["dropdown_hot:solid.sphere", "dropdown_hot:solid.box"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "The keyboard: Down focuses the first row, Down again the next; the highlight follows."
|
||||
},
|
||||
{
|
||||
"key": "ArrowDown"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:sketch.create"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ArrowDown"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:solid.extrude"],
|
||||
"hidden_handles": ["dropdown_hot:sketch.create"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": ["dropdown:solid.box"]
|
||||
}
|
||||
}
|
||||
]
|
||||
Reference in new issue
Block a user