Selection feedback and origin: hover and selected highlights (theme tokens), origin planes/axes/point at a fixed screen size with browser eye toggles, click-to-pick sketch planes and faces with camera animation (Finish Sketch returns), multi-select dialog inputs with pre-selection, tangent chains and face-to-edges, window/crossing box selection, shell/draft/mirror/hole dialogs
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
059909fb05
commit
97e73b0935
20 files changed
+1907
-518
No files matched your search
+8
-6
@@ -7,10 +7,10 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Where are we? | **M0 (vertical slice) done.** Sketch with solver, extrude/revolve/fillet/chamfer/cut, parameters that rebuild the timeline, STEP/STL export, desktop app, CLI, oracle harness. |
|
||||
| Command parity (SOLID + SKETCH toolbar) | **46 / 291 (16%)** — [docs/parity.md](docs/parity.md) |
|
||||
| Fusion oracle | **2 / 29 parts match** Fusion's measurements (16 need features not built yet) — [docs/oracle.md](docs/oracle.md) |
|
||||
| Tests | 60 (solver, profiles, kernel booleans/blends/measures, expressions, timeline, file formats, camera, engine end-to-end, hostile-input fuzz over every command) |
|
||||
| Where are we? | **M0 done; M1/M2 in progress.** Sketch with solver, extrude (taper, two-sided, through all), revolve, fillet/chamfer, holes, patterns, mirror, shell, draft, loft, sweep, split, construction planes, parameters that rebuild the timeline, STEP/STL export, desktop app with Fusion-style selection (hover/selected highlights, origin planes, click-to-pick sketch planes, multi-select, window/crossing box selection, animated view cube), CLI, MCP server, oracle harness. |
|
||||
| Command parity (SOLID + SKETCH toolbar) | **57 / 291 (20%)** — [docs/parity.md](docs/parity.md) |
|
||||
| Fusion oracle | **23 / 29 parts match** Fusion's measurements — [docs/oracle.md](docs/oracle.md) |
|
||||
| Tests | 84 (solver, profiles, kernel booleans/blends/measures, expressions, timeline, file formats, camera, engine end-to-end, hostile-input fuzz over every command) |
|
||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering — green |
|
||||
| Weighted parity estimate | **≈ 5%** of Fusion's Design workspace by importance (sketch + basic solids are the core, but surfaces, assemblies, sheet metal, CAM, drawings are untouched) |
|
||||
| Time to a useful alpha (M0–M6) | ≈ **150 agent hours** remain |
|
||||
@@ -21,11 +21,11 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
|---|---|---|---|
|
||||
| M0 | Vertical slice | done | — |
|
||||
| M1 | Sketch depth, oracle sketch cases (arcs, taper, two-sided, sketch modify tools) | in progress | 16 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes, patterns, mirror, shell, draft, split, construction geometry | planned | 24 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 |
|
||||
| M4 | Files: STEP import, mesh import, DXF sketches, 3MF | planned | 16 |
|
||||
| M5 | Persistent naming of faces and edges | planned | 20 |
|
||||
| M6 | UI depth: profile shading, selection filters, measure tool, section view, edit dialogs, drag-solve | planned | 24 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (edit feature, rollback marker, reorder, suppress, re-resolved references), live previews, measure tool, section view, drag-solve | in progress | 24 |
|
||||
| M7 | Sweep, loft, rib, web, emboss, thread, coil, pipe | planned | 24 |
|
||||
| M8 | Surface workspace | planned | 30 |
|
||||
| M9 | Assemblies and joints | planned | 40 |
|
||||
@@ -41,6 +41,8 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
extended automatically); fully internal voids are not supported.
|
||||
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative).
|
||||
- No STEP import yet; no persistent naming (fillet edges are re-found by position).
|
||||
- Shell and draft work on convex bodies with planar faces; loft is ruled (no tangency end conditions).
|
||||
- Dialogs have no live preview of the result yet.
|
||||
|
||||
## Kernel
|
||||
|
||||
|
||||
@@ -99,6 +99,19 @@ pub enum Sel {
|
||||
sketch: u64,
|
||||
index: usize,
|
||||
},
|
||||
/// An origin plane (XY, XZ, YZ) or a construction plane (by feature name).
|
||||
Plane {
|
||||
name: String,
|
||||
},
|
||||
/// An origin axis (X, Y, Z).
|
||||
Axis {
|
||||
name: String,
|
||||
},
|
||||
/// A body vertex at a point.
|
||||
Vertex {
|
||||
body: String,
|
||||
point: Vec3,
|
||||
},
|
||||
}
|
||||
|
||||
/// One undo step.
|
||||
|
||||
@@ -312,3 +312,27 @@ fn sample_design_builds() {
|
||||
let v = m["total"]["volume_mm3"].as_f64().unwrap();
|
||||
assert!(rel(v, plate + boss - bore) < 1e-3, "{v} vs {}", plate + boss - bore);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_edges_chain_and_faces() {
|
||||
let mut s = Session::default();
|
||||
s.run_script(&crate::sample::script()).unwrap();
|
||||
let st = s.model.state();
|
||||
let m = st.bodies[0].mesh();
|
||||
let e = (0..m.edges.len()).find(|i| m.edges[*i].iter().all(|p| (p.z - 8.0).abs() < 1e-6 && p.y.abs() < 1e-6)).expect("front top edge");
|
||||
let chain = m.tangent_chain(e, 2f64.to_radians());
|
||||
// The plate's top outline: 4 lines and 4 corner arcs (a boolean may leave an edge twice,
|
||||
// once per face; count distinct ones).
|
||||
let mut mids: Vec<solvecraft_geom::Vec3> = Vec::new();
|
||||
for i in &chain {
|
||||
let p = &m.edges[*i];
|
||||
let mid = p[p.len() / 2];
|
||||
assert!(p.iter().all(|q| (q.z - 8.0).abs() < 1e-6), "{p:?}");
|
||||
if !mids.iter().any(|q| q.dist(mid) < 1e-6) {
|
||||
mids.push(mid);
|
||||
}
|
||||
}
|
||||
assert_eq!(mids.len(), 8, "{chain:?}");
|
||||
// Every edge bounds a face, and the top face has the outline and the hole rims.
|
||||
assert!(m.edge_faces.iter().all(|f| !f.is_empty()));
|
||||
}
|
||||
@@ -143,3 +143,17 @@ pub fn home_camera(s: &Session) -> Camera {
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Construction planes of the timeline: (feature id, name, plane).
|
||||
pub fn construction_planes(s: &Session) -> Vec<(u64, String, Plane)> {
|
||||
use solvecraft_doc::FeatureKind;
|
||||
let (vals, _) = s.doc.param_values();
|
||||
s.doc
|
||||
.features
|
||||
.iter()
|
||||
.filter_map(|f| match &f.kind {
|
||||
FeatureKind::ConstructionPlane { plane } if !f.suppressed => s.doc.resolve_plane(&vals, plane, 0).ok().map(|p| (f.id, f.name.clone(), p)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -64,6 +64,9 @@ pub struct Mesh {
|
||||
/// Per edge: a seam between two pieces of the same smooth surface (not drawn).
|
||||
#[serde(default)]
|
||||
pub seams: Vec<bool>,
|
||||
/// Per edge: the B-rep faces it bounds (one or two).
|
||||
#[serde(default)]
|
||||
pub edge_faces: Vec<Vec<u32>>,
|
||||
}
|
||||
|
||||
/// Mass properties of a closed mesh.
|
||||
@@ -75,6 +78,53 @@ pub struct MeshMeasure {
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
/// Edges bounding face `f`.
|
||||
pub fn face_edges(&self, f: u32) -> Vec<usize> {
|
||||
self.edge_faces.iter().enumerate().filter(|(_, fs)| fs.contains(&f)).map(|(i, _)| i).collect()
|
||||
}
|
||||
/// The edges reached from `e` through vertices where the next edge continues smoothly
|
||||
/// (angle between the end tangents below `max_angle`), including `e` itself.
|
||||
pub fn tangent_chain(&self, e: usize, max_angle: f64) -> Vec<usize> {
|
||||
// (end point, direction leaving the edge there). A polyline's end chord leans inward
|
||||
// on a curve by half the turn to the next chord; extrapolating the two end chords
|
||||
// recovers the curve's end tangent.
|
||||
let end_dir = |p0: Vec3, p1: Vec3, p2: Option<Vec3>| -> Option<Vec3> {
|
||||
let u1 = (p0 - p1).normalized()?;
|
||||
match p2.and_then(|p2| (p1 - p2).normalized()) {
|
||||
Some(u2) => (u1 * 1.5 - u2 * 0.5).normalized(),
|
||||
None => Some(u1),
|
||||
}
|
||||
};
|
||||
let ends = |i: usize| -> Option<[(Vec3, Vec3); 2]> {
|
||||
let p = self.edges.get(i)?;
|
||||
let n = p.len();
|
||||
let (a, a1, a2) = (*p.first()?, *p.get(1)?, p.get(2).copied().filter(|_| n > 2));
|
||||
let (b, b1) = (*p.last()?, *p.get(n.checked_sub(2)?)?);
|
||||
let b2 = n.checked_sub(3).and_then(|k| p.get(k)).copied();
|
||||
Some([(a, end_dir(a, a1, a2)?), (b, end_dir(b, b1, b2)?)])
|
||||
};
|
||||
let size = self.bounds().diagonal().max(1e-9);
|
||||
let tol = size * 1e-7;
|
||||
let cos = max_angle.cos();
|
||||
let mut chain = vec![e];
|
||||
let mut todo = vec![e];
|
||||
while let Some(cur) = todo.pop() {
|
||||
let Some(ce) = ends(cur) else { continue };
|
||||
for (i, _) in self.edges.iter().enumerate() {
|
||||
if chain.contains(&i) || self.seams.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let Some(ne) = ends(i) else { continue };
|
||||
// Smooth: leaving `cur` at a shared end continues into `i` (opposite directions).
|
||||
let smooth = ce.iter().any(|(p, d)| ne.iter().any(|(q, dn)| p.dist(*q) < tol && d.dot(-*dn) > cos));
|
||||
if smooth && chain.len() < 10_000 {
|
||||
chain.push(i);
|
||||
todo.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
chain
|
||||
}
|
||||
pub fn tri(&self, t: &[u32; 3]) -> Option<[Vec3; 3]> {
|
||||
Some([*self.positions.get(t[0] as usize)?, *self.positions.get(t[1] as usize)?, *self.positions.get(t[2] as usize)?])
|
||||
}
|
||||
|
||||
@@ -233,6 +233,44 @@ impl Region2 {
|
||||
pub fn contains(&self, p: Vec2) -> bool {
|
||||
self.outer.contains(p) && !self.holes.iter().any(|h| h.contains(p))
|
||||
}
|
||||
/// Triangles covering the region (for filled display), by slabs between the vertex
|
||||
/// heights: inside each slab the boundary edges don't cross, so consecutive crossings pair
|
||||
/// up into trapezoids.
|
||||
pub fn triangulate(&self, tol: f64) -> Vec<[Vec2; 3]> {
|
||||
let mut edges: Vec<(Vec2, Vec2)> = Vec::new();
|
||||
for l in std::iter::once(&self.outer).chain(&self.holes) {
|
||||
let pl = l.polyline(tol);
|
||||
let n = pl.len();
|
||||
for i in 0..n {
|
||||
let (Some(a), Some(b)) = (pl.get(i), pl.get((i + 1) % n)) else { continue };
|
||||
if (a.y - b.y).abs() > 1e-12 {
|
||||
edges.push(if a.y < b.y { (*a, *b) } else { (*b, *a) });
|
||||
}
|
||||
}
|
||||
}
|
||||
if edges.len() > 20_000 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut ys: Vec<f64> = edges.iter().flat_map(|(a, b)| [a.y, b.y]).collect();
|
||||
ys.sort_by(f64::total_cmp);
|
||||
ys.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
|
||||
let x_at = |e: &(Vec2, Vec2), y: f64| e.0.x + (y - e.0.y) / (e.1.y - e.0.y) * (e.1.x - e.0.x);
|
||||
let mut out = Vec::new();
|
||||
for w in ys.windows(2) {
|
||||
let (y0, y1) = (w[0], w[1]);
|
||||
let ym = (y0 + y1) * 0.5;
|
||||
let mut span: Vec<&(Vec2, Vec2)> = edges.iter().filter(|e| e.0.y <= ym && e.1.y >= ym).collect();
|
||||
span.sort_by(|a, b| x_at(a, ym).total_cmp(&x_at(b, ym)));
|
||||
for pair in span.chunks(2) {
|
||||
let [l, r] = pair else { continue };
|
||||
let (a, b) = (Vec2::new(x_at(l, y0), y0), Vec2::new(x_at(r, y0), y0));
|
||||
let (c, d) = (Vec2::new(x_at(r, y1), y1), Vec2::new(x_at(l, y1), y1));
|
||||
out.push([a, b, c]);
|
||||
out.push([a, c, d]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn centroid(&self) -> Vec2 {
|
||||
let ao = self.outer.signed_area().abs();
|
||||
let mut c = self.outer.centroid() * ao;
|
||||
@@ -250,6 +288,20 @@ impl Region2 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn triangulated_area() {
|
||||
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(5.0, 3.0), Vec2::new(0.0, 8.0)]);
|
||||
let r = Region2 { outer: sq, holes: vec![Loop2::circle(Vec2::new(3.0, 1.5), 1.0).reversed()] };
|
||||
let tris = r.triangulate(1e-3);
|
||||
let a: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).abs() * 0.5).sum();
|
||||
// The circle is a polyline within the tolerance, so the areas agree to about that.
|
||||
assert!((a - r.area()).abs() < 1e-3 * r.area(), "{a} vs {}", r.area());
|
||||
for [a, b, c] in &tris {
|
||||
let m = (*a + *b + *c) / 3.0;
|
||||
assert!((*b - *a).cross(*c - *a).abs() < 1e-12 || r.contains(m), "{m:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn areas() {
|
||||
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 3.0), Vec2::new(0.0, 3.0)]);
|
||||
|
||||
@@ -6,7 +6,7 @@ fn cube_mesh(s: f64) -> Mesh {
|
||||
let triangles =
|
||||
vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]];
|
||||
let n = triangles.len();
|
||||
Mesh { normals: vec![Vec3::Z; 8], positions, triangles, tri_face: vec![0; n], edges: vec![], seams: vec![] }
|
||||
Mesh { normals: vec![Vec3::Z; 8], positions, triangles, tri_face: vec![0; n], edges: vec![], seams: vec![], edge_faces: vec![] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -62,3 +62,24 @@ fn aabb() {
|
||||
b.add(Vec3::new(-1.0, 0.0, 0.0));
|
||||
assert_eq!(b.size(), Vec3::new(2.0, 2.0, 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tangent_chains_follow_smooth_edges() {
|
||||
// A line, then an arc leaving it tangentially, then a sharp corner.
|
||||
let mut m = Mesh { positions: vec![Vec3::ZERO, Vec3::new(30.0, 30.0, 0.0)], ..Default::default() };
|
||||
m.edges.push(vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0)]);
|
||||
let arc: Vec<Vec3> = (0..=16)
|
||||
.map(|k| {
|
||||
let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::FRAC_PI_2 * k as f64 / 16.0;
|
||||
Vec3::new(10.0 + 5.0 * a.cos(), 5.0 + 5.0 * a.sin(), 0.0)
|
||||
})
|
||||
.collect();
|
||||
m.edges.push(arc);
|
||||
m.edges.push(vec![Vec3::new(15.0, 5.0, 0.0), Vec3::new(5.0, 5.0, 0.0)]);
|
||||
m.edge_faces = vec![vec![0], vec![0, 1], vec![1]];
|
||||
let mut c = m.tangent_chain(0, 5f64.to_radians());
|
||||
c.sort();
|
||||
assert_eq!(c, vec![0, 1]);
|
||||
assert_eq!(m.tangent_chain(2, 5f64.to_radians()), vec![2]);
|
||||
assert_eq!(m.face_edges(1), vec![1, 2]);
|
||||
}
|
||||
@@ -141,14 +141,27 @@ impl Body {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
for e in meshed.edge_iter() {
|
||||
if !seen.insert(e.id()) {
|
||||
if seen.contains_key(&e.id()) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(e.id(), out.edges.len());
|
||||
let c = e.oriented_curve();
|
||||
out.edges.push(c.0.iter().map(|p| Vec3::new(p.x, p.y, p.z)).collect());
|
||||
}
|
||||
// Which faces each edge bounds.
|
||||
out.edge_faces = vec![Vec::new(); out.edges.len()];
|
||||
for (fi, face) in meshed.face_iter().enumerate() {
|
||||
for e in face.edge_iter() {
|
||||
if let Some(slot) = seen.get(&e.id()).and_then(|i| out.edge_faces.get_mut(*i)) {
|
||||
let f = u32::try_from(fi).unwrap_or(u32::MAX);
|
||||
if !slot.contains(&f) {
|
||||
slot.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -220,6 +220,7 @@ mod tests {
|
||||
tri_face: vec![0],
|
||||
edges: vec![],
|
||||
seams: vec![],
|
||||
edge_faces: vec![],
|
||||
};
|
||||
let scene =
|
||||
Scene { meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(200, 30, 30) }], lines: vec![], background: None, radius: 20.0 };
|
||||
|
||||
@@ -29,6 +29,24 @@ fn eye(ui: &mut egui::Ui, visible: bool) -> bool {
|
||||
resp.on_hover_text(if visible { "Hide" } else { "Show" }).clicked()
|
||||
}
|
||||
|
||||
/// A browser click on a plane or axis: it goes to the open dialog's input if that takes it,
|
||||
/// otherwise it becomes the selection.
|
||||
fn pick_from_browser(app: &mut SolveApp, x: Sel) {
|
||||
let hit = match &x {
|
||||
Sel::Plane { name } => crate::viewport::Hit::Plane { name: name.clone(), point: solvecraft_engine::geom::Vec3::ZERO },
|
||||
Sel::Axis { name } => crate::viewport::Hit::Axis { name: name.clone() },
|
||||
_ => return,
|
||||
};
|
||||
if let Some(mut d) = app.dialog.take() {
|
||||
if let Some(sel) = d.candidate(&app.session, &hit) {
|
||||
d.pick(&app.session, sel);
|
||||
}
|
||||
app.dialog = Some(d);
|
||||
return;
|
||||
}
|
||||
let _ = app.run("select.set", json!({ "items": [x] }));
|
||||
}
|
||||
|
||||
pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
egui::Panel::left("sc_browser")
|
||||
@@ -53,16 +71,38 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
app.start("ChangeParameterCommand");
|
||||
}
|
||||
});
|
||||
egui::CollapsingHeader::new("Origin").default_open(false).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if eye(ui, app.ui.show_origin) {
|
||||
app.ui.show_origin = !app.ui.show_origin;
|
||||
}
|
||||
ui.label("O, X, Y, Z");
|
||||
});
|
||||
for p in ["XY", "XZ", "YZ"] {
|
||||
row(ui, "plane", p, None, false);
|
||||
ui.horizontal(|ui| {
|
||||
if eye(ui, app.ui.show_origin) {
|
||||
app.ui.show_origin = !app.ui.show_origin;
|
||||
}
|
||||
egui::CollapsingHeader::new("Origin").default_open(false).show(ui, |ui| {
|
||||
let items = [("O", "point", "Origin point"), ("X", "axis", "X axis"), ("Y", "axis", "Y axis"), ("Z", "axis", "Z axis")]
|
||||
.into_iter()
|
||||
.chain([("XY", "plane", "XY"), ("XZ", "plane", "XZ"), ("YZ", "plane", "YZ")]);
|
||||
for (key, icon, label) in items {
|
||||
let visible = !app.ui.hidden_origin.iter().any(|h| h == key);
|
||||
let sel = match icon {
|
||||
"plane" => Some(Sel::Plane { name: key.into() }),
|
||||
"axis" => Some(Sel::Axis { name: key.into() }),
|
||||
_ => None,
|
||||
};
|
||||
let selected = sel.as_ref().is_some_and(|x| app.session.selection.contains(x));
|
||||
ui.horizontal(|ui| {
|
||||
if eye(ui, visible) {
|
||||
if visible {
|
||||
app.ui.hidden_origin.push(key.into());
|
||||
} else {
|
||||
app.ui.hidden_origin.retain(|h| h != key);
|
||||
}
|
||||
}
|
||||
if row(ui, icon, label, None, selected).clicked()
|
||||
&& let Some(x) = &sel
|
||||
{
|
||||
pick_from_browser(app, x.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
egui::CollapsingHeader::new(format!("Bodies ({})", st.bodies.len())).default_open(true).show(ui, |ui| {
|
||||
for b in &st.bodies {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//! - `engine.commands`: every command with tab, panel, params and enablement
|
||||
//! - `document.inspect {measure?}`: the design (parameters, timeline, bodies, sketches)
|
||||
//! - `ui.inspect`: UI state, viewport rect, camera, tool and dialog
|
||||
//! - `ui.drag {x0, y0, x1, y1, button?, shift?, ctrl?, steps?}` (box selection, navigation);
|
||||
//! `ui.selection` (selection, dialog inputs, hover)
|
||||
//! - `ui.set {...UiState fields}`; `ui.view {view: front|back|top|bottom|left|right|iso|home|fit, animate?: bool}` (snaps unless animate)
|
||||
//! - `ui.start {command}`: like clicking the toolbar button (starts tools/dialogs)
|
||||
//! - `ui.click {x, y, button?, shift?}`, `ui.move {x, y}`, `ui.scroll {x, y, delta}`: real
|
||||
@@ -147,12 +149,35 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
Some("middle") => egui::PointerButton::Middle,
|
||||
_ => egui::PointerButton::Primary,
|
||||
};
|
||||
let modifiers = egui::Modifiers { shift: b("shift"), ..Default::default() };
|
||||
let modifiers = egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), ..Default::default() };
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers });
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers });
|
||||
}
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.drag" => {
|
||||
let (Some(x0), Some(y0), Some(x1), Some(y1)) = (f("x0"), f("y0"), f("x1"), f("y1")) else { return err("missing x0/y0/x1/y1") };
|
||||
let button = match s("button") {
|
||||
Some("right") => egui::PointerButton::Secondary,
|
||||
Some("middle") => egui::PointerButton::Middle,
|
||||
_ => egui::PointerButton::Primary,
|
||||
};
|
||||
let modifiers = egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), ..Default::default() };
|
||||
let (a, z) = (egui::pos2(x0 as f32, y0 as f32), egui::pos2(x1 as f32, y1 as f32));
|
||||
app.synthetic.push(egui::Event::PointerMoved(a));
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
|
||||
let steps = f("steps").unwrap_or(10.0).clamp(1.0, 200.0) as usize;
|
||||
for k in 1..=steps {
|
||||
app.synthetic.push(egui::Event::PointerMoved(a + (z - a) * (k as f32 / steps as f32)));
|
||||
}
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: z, button, pressed: false, modifiers });
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.selection" => ok(json!({
|
||||
"selection": app.session.selection,
|
||||
"dialog": app.dialog.as_ref().map(|d| d.inputs.iter().map(|i| json!({"label": i.label, "items": i.items})).collect::<Vec<_>>()),
|
||||
"hover": app.viewport.hover.as_ref().map(|h| format!("{h:?}")),
|
||||
})),
|
||||
"ui.scroll" => {
|
||||
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
|
||||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x as f32, y as f32)));
|
||||
|
||||
+608
-326
File diff suppressed because it is too large.
Load diff
+79
-34
@@ -13,10 +13,15 @@ pub const TRI_SIZE: usize = 28;
|
||||
pub const LINE_SIZE: usize = 32;
|
||||
const UNIFORM_SIZE: u64 = 112;
|
||||
|
||||
/// CPU-side geometry waiting to be uploaded.
|
||||
/// CPU-side geometry waiting to be uploaded. The model scene changes with the design; the
|
||||
/// highlight scene (hover, selection, the origin widget) changes often and is small.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct GpuScene {
|
||||
/// Opaque triangles. A zero normal draws the colour flat (unlit).
|
||||
pub tris: Vec<u8>,
|
||||
/// Translucent triangles (alpha from the colour), drawn after the opaque ones without
|
||||
/// writing depth.
|
||||
pub trans: Vec<u8>,
|
||||
/// Depth-tested lines.
|
||||
pub lines: Vec<u8>,
|
||||
/// Lines drawn over everything (active sketch, highlights).
|
||||
@@ -30,6 +35,12 @@ impl GpuScene {
|
||||
}
|
||||
self.tris.extend_from_slice(&c);
|
||||
}
|
||||
pub fn trans_tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) {
|
||||
for v in p.iter().chain(&n) {
|
||||
self.trans.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
self.trans.extend_from_slice(&c);
|
||||
}
|
||||
pub fn line(&mut self, a: [f32; 3], b: [f32; 3], c: [u8; 4], width: f32, on_top: bool) {
|
||||
let out = if on_top { &mut self.overlay } else { &mut self.lines };
|
||||
for v in a.iter().chain(&b) {
|
||||
@@ -46,6 +57,9 @@ pub type SceneSlot = Arc<Mutex<Option<GpuScene>>>;
|
||||
pub struct ViewportCallback {
|
||||
pub key: u64,
|
||||
pub slot: SceneSlot,
|
||||
/// The highlight scene and its key.
|
||||
pub hl_key: u64,
|
||||
pub hl_slot: SceneSlot,
|
||||
/// Column-major view-projection.
|
||||
pub view_proj: [[f32; 4]; 4],
|
||||
/// Direction toward the eye (for lighting).
|
||||
@@ -59,17 +73,42 @@ struct Batch {
|
||||
count: u32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Batches {
|
||||
key: Option<u64>,
|
||||
tris: Option<Batch>,
|
||||
trans: Option<Batch>,
|
||||
lines: Option<Batch>,
|
||||
overlays: Option<Batch>,
|
||||
}
|
||||
|
||||
impl Batches {
|
||||
fn take(&mut self, device: &wgpu::Device, key: u64, slot: &SceneSlot) {
|
||||
if self.key == Some(key) {
|
||||
return;
|
||||
}
|
||||
if let Some(sc) = slot.lock().ok().and_then(|mut s| s.take()) {
|
||||
self.tris = upload(device, "sc_tris", &sc.tris, TRI_SIZE);
|
||||
self.trans = upload(device, "sc_trans", &sc.trans, TRI_SIZE);
|
||||
self.lines = upload(device, "sc_lines", &sc.lines, LINE_SIZE);
|
||||
self.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE);
|
||||
self.key = Some(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Resources {
|
||||
tri: wgpu::RenderPipeline,
|
||||
/// Highlight triangles over the model (depth test ≤, no depth write).
|
||||
tri_hl: wgpu::RenderPipeline,
|
||||
trans: wgpu::RenderPipeline,
|
||||
line: wgpu::RenderPipeline,
|
||||
overlay: wgpu::RenderPipeline,
|
||||
uniform: wgpu::Buffer,
|
||||
bind: wgpu::BindGroup,
|
||||
linear_out: bool,
|
||||
key: Option<u64>,
|
||||
tris: Option<Batch>,
|
||||
lines: Option<Batch>,
|
||||
overlays: Option<Batch>,
|
||||
model: Batches,
|
||||
highlight: Batches,
|
||||
}
|
||||
|
||||
const SHADER: &str = r#"
|
||||
@@ -108,6 +147,10 @@ fn vs_tri(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c:
|
||||
|
||||
@fragment
|
||||
fn fs_tri(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
// A zero normal marks flat colour (selection fills, translucent planes).
|
||||
if (length(i.n) < 0.5) {
|
||||
return out_color(i.c);
|
||||
}
|
||||
let v = normalize(u.back.xyz);
|
||||
var n = normalize(i.n);
|
||||
if (dot(n, v) < 0.0) { n = -n; }
|
||||
@@ -117,7 +160,7 @@ fn fs_tri(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32>
|
||||
let h = normalize(key + v);
|
||||
let spec = pow(max(dot(n, h), 0.0), 40.0) * 0.25;
|
||||
let k = 0.42 + 0.38 * diff + 0.25 * fill;
|
||||
return out_color(vec4<f32>(min(i.c.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), 1.0));
|
||||
return out_color(vec4<f32>(min(i.c.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), i.c.a));
|
||||
}
|
||||
|
||||
struct LOut {
|
||||
@@ -202,7 +245,7 @@ impl Resources {
|
||||
layout: &bgl,
|
||||
entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }],
|
||||
});
|
||||
let depth = |compare: wgpu::CompareFunction, write: bool| {
|
||||
let depth = |compare: wgpu::CompareFunction, write: bool| -> Option<wgpu::DepthStencilState> {
|
||||
t.depth.map(|format| wgpu::DepthStencilState {
|
||||
format,
|
||||
depth_write_enabled: Some(write),
|
||||
@@ -237,15 +280,15 @@ impl Resources {
|
||||
let alpha = Some(wgpu::BlendState::ALPHA_BLENDING);
|
||||
Resources {
|
||||
tri: pipeline("sc_tris", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Less, true), None),
|
||||
tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
|
||||
trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
|
||||
line: pipeline("sc_lines", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
|
||||
overlay: pipeline("sc_overlay", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::Always, false), alpha),
|
||||
uniform,
|
||||
bind,
|
||||
linear_out: t.format.is_srgb(),
|
||||
key: None,
|
||||
tris: None,
|
||||
lines: None,
|
||||
overlays: None,
|
||||
model: Batches::default(),
|
||||
highlight: Batches::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,14 +325,8 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
resources: &mut egui_wgpu::CallbackResources,
|
||||
) -> Vec<wgpu::CommandBuffer> {
|
||||
let Some(res) = resources.get_mut::<Resources>() else { return Vec::new() };
|
||||
if res.key != Some(self.key)
|
||||
&& let Some(sc) = self.slot.lock().ok().and_then(|mut s| s.take())
|
||||
{
|
||||
res.tris = upload(device, "sc_tris", &sc.tris, TRI_SIZE);
|
||||
res.lines = upload(device, "sc_lines", &sc.lines, LINE_SIZE);
|
||||
res.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE);
|
||||
res.key = Some(self.key);
|
||||
}
|
||||
res.model.take(device, self.key, &self.slot);
|
||||
res.highlight.take(device, self.hl_key, &self.hl_slot);
|
||||
queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out));
|
||||
Vec::new()
|
||||
}
|
||||
@@ -302,21 +339,29 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
}
|
||||
pass.set_viewport(vp.left_px as f32, vp.top_px as f32, vp.width_px as f32, vp.height_px as f32, 0.0, 1.0);
|
||||
pass.set_bind_group(0, &res.bind, &[]);
|
||||
if let Some(b) = &res.tris {
|
||||
pass.set_pipeline(&res.tri);
|
||||
pass.set_vertex_buffer(0, b.buffer.slice(..));
|
||||
pass.draw(0..b.count, 0..1);
|
||||
}
|
||||
if let Some(b) = &res.lines {
|
||||
pass.set_pipeline(&res.line);
|
||||
pass.set_vertex_buffer(0, b.buffer.slice(..));
|
||||
pass.draw(0..6, 0..b.count);
|
||||
}
|
||||
if let Some(b) = &res.overlays {
|
||||
pass.set_pipeline(&res.overlay);
|
||||
pass.set_vertex_buffer(0, b.buffer.slice(..));
|
||||
pass.draw(0..6, 0..b.count);
|
||||
}
|
||||
let tris = |pass: &mut wgpu::RenderPass<'static>, p: &wgpu::RenderPipeline, b: &Option<Batch>| {
|
||||
if let Some(b) = b {
|
||||
pass.set_pipeline(p);
|
||||
pass.set_vertex_buffer(0, b.buffer.slice(..));
|
||||
pass.draw(0..b.count, 0..1);
|
||||
}
|
||||
};
|
||||
let lines = |pass: &mut wgpu::RenderPass<'static>, p: &wgpu::RenderPipeline, b: &Option<Batch>| {
|
||||
if let Some(b) = b {
|
||||
pass.set_pipeline(p);
|
||||
pass.set_vertex_buffer(0, b.buffer.slice(..));
|
||||
pass.draw(0..6, 0..b.count);
|
||||
}
|
||||
};
|
||||
let (m, h) = (&res.model, &res.highlight);
|
||||
tris(pass, &res.tri, &m.tris);
|
||||
tris(pass, &res.tri_hl, &h.tris);
|
||||
lines(pass, &res.line, &m.lines);
|
||||
lines(pass, &res.line, &h.lines);
|
||||
tris(pass, &res.trans, &m.trans);
|
||||
tris(pass, &res.trans, &h.trans);
|
||||
lines(pass, &res.overlay, &m.overlays);
|
||||
lines(pass, &res.overlay, &h.overlays);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -402,6 +402,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
pen.arrow((6.0, 18.0), (15.0, 9.0), Color32::from_rgb(60, 160, 60));
|
||||
}
|
||||
"plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], Color32::from_rgb(244, 214, 150), ink),
|
||||
"axis" => {
|
||||
pen.line(&[(4.0, 20.0), (20.0, 4.0)], ink);
|
||||
pen.circle(4.0, 20.0, 2.0, ink, ink);
|
||||
}
|
||||
"settings" => {
|
||||
pen.circle(12.0, 12.0, 6.5, pen.fill, ink);
|
||||
pen.circle(12.0, 12.0, 2.5, Color32::WHITE, ink);
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod dialogs;
|
||||
pub mod gpu;
|
||||
pub mod icons;
|
||||
pub mod palette;
|
||||
pub mod selection;
|
||||
pub mod theme;
|
||||
pub mod timeline;
|
||||
pub mod toolbar;
|
||||
@@ -41,6 +42,8 @@ pub struct UiState {
|
||||
pub show_origin: bool,
|
||||
pub perspective: bool,
|
||||
pub hidden_bodies: Vec<String>,
|
||||
/// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction planes hidden one by one.
|
||||
pub hidden_origin: Vec<String>,
|
||||
pub show_sketches: bool,
|
||||
pub palette_open: bool,
|
||||
}
|
||||
@@ -55,6 +58,7 @@ impl Default for UiState {
|
||||
show_origin: true,
|
||||
perspective: false,
|
||||
hidden_bodies: Vec::new(),
|
||||
hidden_origin: Vec::new(),
|
||||
show_sketches: true,
|
||||
palette_open: false,
|
||||
}
|
||||
@@ -76,6 +80,8 @@ pub struct SolveApp {
|
||||
pub cam_anim: Option<CameraAnim>,
|
||||
/// Seconds since start, as of this frame.
|
||||
pub now: f64,
|
||||
/// The view before the current sketch started (Finish Sketch returns to it).
|
||||
pub pre_sketch_cam: Option<Camera>,
|
||||
pub services: Services,
|
||||
pub viewport: viewport::ViewportState,
|
||||
pub tool: Option<tools::Tool>,
|
||||
@@ -101,6 +107,7 @@ impl SolveApp {
|
||||
cam: Camera::default(),
|
||||
cam_anim: None,
|
||||
now: 0.0,
|
||||
pre_sketch_cam: None,
|
||||
services,
|
||||
viewport: viewport::ViewportState::default(),
|
||||
tool: None,
|
||||
@@ -159,18 +166,45 @@ impl SolveApp {
|
||||
/// that have them, otherwise run it with defaults.
|
||||
pub fn start(&mut self, id: &str) {
|
||||
self.tool = None;
|
||||
if id == "SketchStop" {
|
||||
let r = self.run(id, json!({}));
|
||||
if r.is_ok()
|
||||
&& let Some(c) = self.pre_sketch_cam.take()
|
||||
{
|
||||
self.animate_to(c);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(t) = tools::Tool::for_command(id) {
|
||||
self.tool = Some(t);
|
||||
self.set_status(tools::hint(id), false);
|
||||
return;
|
||||
}
|
||||
if let Some(d) = dialogs::Dialog::for_command(self, id) {
|
||||
// The pre-selection now belongs to the dialog's inputs.
|
||||
if d.wants_picks() && !self.session.selection.is_empty() {
|
||||
let _ = self.session.execute("select.clear", &json!({}));
|
||||
}
|
||||
self.dialog = Some(d);
|
||||
return;
|
||||
}
|
||||
let _ = self.run(id, json!({}));
|
||||
}
|
||||
|
||||
/// Everything shown as selected: the selection plus the open dialog's inputs.
|
||||
pub fn highlighted(&self) -> Vec<solvecraft_engine::Sel> {
|
||||
let mut v = self.session.selection.clone();
|
||||
if let Some(d) = &self.dialog {
|
||||
v.extend(d.items());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Is the origin widget shown (always while picking a sketch plane)?
|
||||
pub fn origin_visible(&self) -> bool {
|
||||
self.ui.show_origin || self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::Sketch))
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, s: impl Into<String>, error: bool) {
|
||||
self.status = Some((s.into(), now_ms(), error));
|
||||
}
|
||||
@@ -279,6 +313,14 @@ impl SolveApp {
|
||||
if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() {
|
||||
raw.events.push(egui::Event::PointerMoved(*p));
|
||||
}
|
||||
// egui keeps the modifier keys as input state, changed by their own event.
|
||||
match self.synthetic.first() {
|
||||
Some(egui::Event::PointerButton { modifiers, .. } | egui::Event::Key { modifiers, .. }) => {
|
||||
raw.events.push(egui::Event::ModifiersChanged(*modifiers))
|
||||
}
|
||||
Some(egui::Event::PointerMoved(_)) => raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::default())),
|
||||
_ => {}
|
||||
}
|
||||
let n = n.min(self.synthetic.len());
|
||||
raw.events.extend(self.synthetic.drain(..n));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Selection inputs and the geometry behind picking: what a command input accepts, the origin
|
||||
//! widget (a fixed size on screen), ray tests against planar quads, and window/crossing box
|
||||
//! selection. The pure parts are unit tested; the viewport uses them.
|
||||
|
||||
use egui::{Pos2, Rect};
|
||||
use solvecraft_engine::Sel;
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
|
||||
use crate::viewport::Hit;
|
||||
|
||||
/// What a selection input accepts (bit set).
|
||||
pub type Accept = u16;
|
||||
pub const PROFILES: Accept = 1;
|
||||
pub const EDGES: Accept = 2;
|
||||
pub const FACES: Accept = 4;
|
||||
/// Planar faces only.
|
||||
pub const PLANAR_FACES: Accept = 8;
|
||||
pub const BODIES: Accept = 16;
|
||||
/// Origin and construction planes.
|
||||
pub const PLANES: Accept = 32;
|
||||
/// Origin axes and straight sketch curves.
|
||||
pub const AXES: Accept = 64;
|
||||
pub const VERTICES: Accept = 128;
|
||||
|
||||
/// One selection input of a command dialog ("Profiles", "Edges", "Plane", ...).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SelInput {
|
||||
pub label: &'static str,
|
||||
pub accept: Accept,
|
||||
/// Many items (clicks toggle them), or one (a click replaces it).
|
||||
pub multi: bool,
|
||||
pub items: Vec<Sel>,
|
||||
}
|
||||
|
||||
impl SelInput {
|
||||
pub fn new(label: &'static str, accept: Accept, multi: bool) -> SelInput {
|
||||
SelInput { label, accept, multi, items: Vec::new() }
|
||||
}
|
||||
|
||||
/// The selection a hit stands for, if this input accepts it.
|
||||
pub fn accepts(&self, h: &Hit, planar: impl Fn(&str, usize) -> bool) -> Option<Sel> {
|
||||
let a = self.accept;
|
||||
match h {
|
||||
Hit::Profile { sketch, index } if a & PROFILES != 0 => Some(Sel::Profile { sketch: *sketch, index: *index }),
|
||||
Hit::Edge { body, index, mid } if a & EDGES != 0 => Some(Sel::Edge { body: body.clone(), index: *index, point: *mid }),
|
||||
Hit::Face { body, index, point } if a & FACES != 0 || (a & PLANAR_FACES != 0 && planar(body, *index)) => {
|
||||
Some(Sel::Face { body: body.clone(), index: *index, point: *point })
|
||||
}
|
||||
Hit::Face { body, .. } if a & BODIES != 0 => Some(Sel::Body { name: body.clone() }),
|
||||
Hit::Plane { name, .. } if a & PLANES != 0 => Some(Sel::Plane { name: name.clone() }),
|
||||
Hit::Axis { name } if a & AXES != 0 => Some(Sel::Axis { name: name.clone() }),
|
||||
Hit::SketchCurve { id, straight: true, .. } if a & AXES != 0 => Some(Sel::SketchCurve { id: id.clone() }),
|
||||
Hit::Vertex { body, point } if a & VERTICES != 0 => Some(Sel::Vertex { body: body.clone(), point: *point }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or remove items: in a multi input a pick toggles, otherwise it replaces.
|
||||
pub fn toggle(&mut self, picked: Vec<Sel>) {
|
||||
if picked.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !self.multi {
|
||||
self.items = picked.into_iter().take(1).collect();
|
||||
return;
|
||||
}
|
||||
// A pick of something already in (or a chain fully in) takes it out.
|
||||
if picked.iter().all(|p| self.items.contains(p)) {
|
||||
self.items.retain(|x| !picked.contains(x));
|
||||
} else {
|
||||
for p in picked {
|
||||
if !self.items.contains(&p) {
|
||||
self.items.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// World size of the origin widget: a fixed fraction of the viewport height, whatever the zoom
|
||||
/// (`half_height` is half the visible height in mm at the target).
|
||||
pub fn origin_size(half_height: f64) -> f64 {
|
||||
(half_height * 2.0 * 0.075).max(1e-6)
|
||||
}
|
||||
|
||||
/// The three origin plane squares (positive quadrant): name, plane normal and corners.
|
||||
pub fn origin_quads(size: f64) -> [(&'static str, Vec3, [Vec3; 4]); 3] {
|
||||
let s = size;
|
||||
[
|
||||
("XY", Vec3::Z, [Vec3::ZERO, Vec3::new(s, 0.0, 0.0), Vec3::new(s, s, 0.0), Vec3::new(0.0, s, 0.0)]),
|
||||
("XZ", Vec3::new(0.0, -1.0, 0.0), [Vec3::ZERO, Vec3::new(s, 0.0, 0.0), Vec3::new(s, 0.0, s), Vec3::new(0.0, 0.0, s)]),
|
||||
("YZ", Vec3::X, [Vec3::ZERO, Vec3::new(0.0, s, 0.0), Vec3::new(0.0, s, s), Vec3::new(0.0, 0.0, s)]),
|
||||
]
|
||||
}
|
||||
|
||||
/// Ray parameter where the ray (o, d) hits the planar quad (corners in order), if it does.
|
||||
pub fn ray_quad(o: Vec3, d: Vec3, q: &[Vec3; 4]) -> Option<f64> {
|
||||
let [a, b, _, dd] = *q;
|
||||
let (u, v) = (b - a, dd - a);
|
||||
let n = u.cross(v);
|
||||
let den = n.dot(d);
|
||||
if den.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let t = n.dot(a - o) / den;
|
||||
if !(t.is_finite() && t >= 0.0) {
|
||||
return None;
|
||||
}
|
||||
let p = o + d * t - a;
|
||||
// Coordinates in the (u, v) frame (the quad is a parallelogram).
|
||||
let (uu, uv, vv) = (u.dot(u), u.dot(v), v.dot(v));
|
||||
let det = uu * vv - uv * uv;
|
||||
if det.abs() < 1e-300 {
|
||||
return None;
|
||||
}
|
||||
let (pu, pv) = (p.dot(u), p.dot(v));
|
||||
let s = (pu * vv - pv * uv) / det;
|
||||
let r = (pv * uu - pu * uv) / det;
|
||||
((0.0..=1.0).contains(&s) && (0.0..=1.0).contains(&r)).then_some(t)
|
||||
}
|
||||
|
||||
/// A rectangle dragged in the viewport selects by window (dragged left to right: items fully
|
||||
/// inside) or by crossing (right to left: items touching it).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BoxSel {
|
||||
pub rect: Rect,
|
||||
pub crossing: bool,
|
||||
}
|
||||
|
||||
impl BoxSel {
|
||||
pub fn from_drag(start: Pos2, end: Pos2) -> BoxSel {
|
||||
BoxSel { rect: Rect::from_two_pos(start, end), crossing: end.x < start.x }
|
||||
}
|
||||
|
||||
/// Does the polyline (screen points) count as selected?
|
||||
pub fn takes(&self, pts: &[Pos2]) -> bool {
|
||||
if pts.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if !self.crossing {
|
||||
return pts.iter().all(|p| self.rect.contains(*p));
|
||||
}
|
||||
pts.iter().any(|p| self.rect.contains(*p)) || pts.windows(2).any(|w| segment_hits_rect(w[0], w[1], self.rect))
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the segment a–b cross the rectangle's boundary or lie inside it?
|
||||
pub fn segment_hits_rect(a: Pos2, b: Pos2, r: Rect) -> bool {
|
||||
if r.contains(a) || r.contains(b) {
|
||||
return true;
|
||||
}
|
||||
let c = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()];
|
||||
(0..4).any(|i| segments_cross(a, b, c[i], c[(i + 1) % 4]))
|
||||
}
|
||||
|
||||
fn segments_cross(p: Pos2, q: Pos2, r: Pos2, s: Pos2) -> bool {
|
||||
let o = |a: Pos2, b: Pos2, c: Pos2| (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
||||
let (d1, d2, d3, d4) = (o(r, s, p), o(r, s, q), o(p, q, r), o(p, q, s));
|
||||
(d1 > 0.0) != (d2 > 0.0) && (d3 > 0.0) != (d4 > 0.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::pos2;
|
||||
use solvecraft_engine::render::{Camera, StandardView};
|
||||
|
||||
#[test]
|
||||
fn origin_widget_keeps_its_screen_size() {
|
||||
let (w, h) = (1200.0, 800.0);
|
||||
let mut sizes = Vec::new();
|
||||
for distance in [20.0, 200.0, 5000.0] {
|
||||
let mut cam = Camera { distance, ..Default::default() };
|
||||
cam.set_view(StandardView::Front);
|
||||
let s = origin_size(cam.half_height());
|
||||
let a = cam.to_screen(Vec3::ZERO, w, h, distance).unwrap();
|
||||
let b = cam.to_screen(Vec3::new(s, 0.0, 0.0), w, h, distance).unwrap();
|
||||
sizes.push(b.0 - a.0);
|
||||
}
|
||||
assert!(sizes.iter().all(|x| (x - sizes[0]).abs() < 1e-6 && *x > 40.0), "{sizes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rays_hit_origin_quads() {
|
||||
let q = origin_quads(10.0);
|
||||
// Straight down onto XY at (3, 4).
|
||||
let t = ray_quad(Vec3::new(3.0, 4.0, 50.0), Vec3::new(0.0, 0.0, -1.0), &q[0].2).unwrap();
|
||||
assert!((t - 50.0).abs() < 1e-12);
|
||||
// Outside the quadrant: misses.
|
||||
assert!(ray_quad(Vec3::new(-3.0, 4.0, 50.0), Vec3::new(0.0, 0.0, -1.0), &q[0].2).is_none());
|
||||
// Parallel: misses.
|
||||
assert!(ray_quad(Vec3::new(3.0, 4.0, 50.0), Vec3::X, &q[0].2).is_none());
|
||||
// From the front onto XZ.
|
||||
assert!(ray_quad(Vec3::new(5.0, -100.0, 5.0), Vec3::Y, &q[1].2).is_some());
|
||||
// Behind the ray origin: misses.
|
||||
assert!(ray_quad(Vec3::new(3.0, 4.0, -50.0), Vec3::new(0.0, 0.0, -1.0), &q[0].2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_and_crossing_selection() {
|
||||
let inside = [pos2(20.0, 20.0), pos2(40.0, 30.0)];
|
||||
let partly = [pos2(20.0, 20.0), pos2(140.0, 30.0)];
|
||||
let through = [pos2(-10.0, 50.0), pos2(200.0, 50.0)];
|
||||
let away = [pos2(300.0, 300.0), pos2(320.0, 330.0)];
|
||||
// Left to right: window.
|
||||
let w = BoxSel::from_drag(pos2(0.0, 0.0), pos2(100.0, 100.0));
|
||||
assert!(!w.crossing);
|
||||
assert!(w.takes(&inside) && !w.takes(&partly) && !w.takes(&through) && !w.takes(&away));
|
||||
// Right to left: crossing.
|
||||
let c = BoxSel::from_drag(pos2(100.0, 100.0), pos2(0.0, 0.0));
|
||||
assert!(c.crossing);
|
||||
assert!(c.takes(&inside) && c.takes(&partly) && c.takes(&through) && !c.takes(&away));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inputs_toggle_and_filter() {
|
||||
let mut inp = SelInput::new("Edges", EDGES, true);
|
||||
let e = |i: usize| Sel::Edge { body: "B".into(), index: i, point: Vec3::new(i as f64, 0.0, 0.0) };
|
||||
inp.toggle(vec![e(1)]);
|
||||
inp.toggle(vec![e(2), e(3)]);
|
||||
assert_eq!(inp.items.len(), 3);
|
||||
inp.toggle(vec![e(2), e(3)]);
|
||||
assert_eq!(inp.items, vec![e(1)]);
|
||||
let face = Hit::Face { body: "B".into(), index: 0, point: Vec3::ZERO };
|
||||
assert!(inp.accepts(&face, |_, _| true).is_none());
|
||||
let planes = SelInput::new("Plane", PLANES | PLANAR_FACES, false);
|
||||
assert!(planes.accepts(&face, |_, _| true).is_some());
|
||||
assert!(planes.accepts(&face, |_, _| false).is_none());
|
||||
let mut one = SelInput::new("Plane", PLANES, false);
|
||||
one.toggle(vec![Sel::Plane { name: "XY".into() }]);
|
||||
one.toggle(vec![Sel::Plane { name: "YZ".into() }]);
|
||||
assert_eq!(one.items, vec![Sel::Plane { name: "YZ".into() }]);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,38 @@ pub struct Tokens {
|
||||
pub icon_fill: Color32,
|
||||
pub timeline: Color32,
|
||||
pub hover: Color32,
|
||||
// ---- viewport selection feedback ----
|
||||
/// Selected face (flat fill).
|
||||
pub sel_face: Color32,
|
||||
/// Selected edge core and its lighter rim.
|
||||
pub sel_edge: Color32,
|
||||
pub sel_edge_rim: Color32,
|
||||
/// Hovered edge: dark core with a light halo.
|
||||
pub hover_edge: Color32,
|
||||
pub hover_edge_halo: Color32,
|
||||
/// How much lighter a hovered face is (grey levels).
|
||||
pub hover_face_lift: u8,
|
||||
/// Selected profile fill and outline.
|
||||
pub sel_profile: Color32,
|
||||
pub sel_profile_edge: Color32,
|
||||
/// Hovered profile: faint tint and a bright outline.
|
||||
pub hover_profile: Color32,
|
||||
pub hover_profile_edge: Color32,
|
||||
/// Selected vertex dot.
|
||||
pub sel_vertex: Color32,
|
||||
// ---- origin ----
|
||||
pub origin_plane: Color32,
|
||||
pub origin_plane_edge: Color32,
|
||||
pub origin_plane_hover: Color32,
|
||||
pub origin_point: Color32,
|
||||
pub axis_x: Color32,
|
||||
pub axis_y: Color32,
|
||||
pub axis_z: Color32,
|
||||
/// Construction planes.
|
||||
pub construction_plane: Color32,
|
||||
// ---- box selection ----
|
||||
pub box_window: Color32,
|
||||
pub box_crossing: Color32,
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
@@ -44,6 +76,27 @@ impl Tokens {
|
||||
icon_fill: Color32::from_rgb(178, 196, 222),
|
||||
timeline: Color32::from_rgb(232, 235, 240),
|
||||
hover: Color32::from_rgb(226, 234, 246),
|
||||
sel_face: Color32::from_rgb(76, 127, 203),
|
||||
sel_edge: Color32::from_rgb(0, 127, 255),
|
||||
sel_edge_rim: Color32::from_rgb(127, 191, 255),
|
||||
hover_edge: Color32::from_rgb(0, 0, 0),
|
||||
hover_edge_halo: Color32::from_rgb(203, 203, 203),
|
||||
hover_face_lift: 40,
|
||||
sel_profile: Color32::from_rgb(26, 84, 167),
|
||||
sel_profile_edge: Color32::from_rgb(47, 109, 198),
|
||||
hover_profile: Color32::from_rgba_unmultiplied_const(75, 129, 253, 48),
|
||||
hover_profile_edge: Color32::from_rgb(75, 129, 253),
|
||||
sel_vertex: Color32::from_rgb(70, 110, 170),
|
||||
origin_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 94),
|
||||
origin_plane_edge: Color32::from_rgba_unmultiplied_const(214, 140, 84, 200),
|
||||
origin_plane_hover: Color32::from_rgba_unmultiplied_const(120, 130, 230, 150),
|
||||
origin_point: Color32::from_rgb(214, 215, 218),
|
||||
axis_x: Color32::from_rgb(255, 0, 0),
|
||||
axis_y: Color32::from_rgb(0, 255, 0),
|
||||
axis_z: Color32::from_rgb(2, 2, 248),
|
||||
construction_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 70),
|
||||
box_window: Color32::from_rgb(38, 120, 218),
|
||||
box_crossing: Color32::from_rgb(40, 160, 80),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+601
-129
@@ -11,6 +11,7 @@ use solvecraft_engine::view::{colors, grid_step, sketch_consumed, sketch_lines};
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use crate::gpu::{GpuScene, GpuTarget, SceneSlot, ViewportCallback};
|
||||
use crate::selection::{BoxSel, origin_quads, origin_size, ray_quad};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{SolveApp, icons};
|
||||
|
||||
@@ -27,21 +28,59 @@ pub struct ViewportState {
|
||||
pub gpu: Option<GpuTarget>,
|
||||
slot: SceneSlot,
|
||||
key: u64,
|
||||
hl_slot: SceneSlot,
|
||||
hl_key: u64,
|
||||
cpu: Option<(u64, egui::TextureHandle)>,
|
||||
pub nav: Option<NavMode>,
|
||||
/// What a click would pick now (after the active command's filter).
|
||||
pub hover: Option<Hit>,
|
||||
pub mouse: Option<Pos2>,
|
||||
/// Box selection being dragged.
|
||||
pub boxsel: Option<BoxSel>,
|
||||
pub build_ms: f64,
|
||||
}
|
||||
|
||||
/// Something under the cursor.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Hit {
|
||||
SketchPoint { sketch: u64, id: String, at: Vec2 },
|
||||
SketchCurve { sketch: u64, id: String },
|
||||
Edge { body: String, index: usize, mid: Vec3 },
|
||||
Profile { sketch: u64, index: usize },
|
||||
Face { body: String, index: usize, point: Vec3 },
|
||||
SketchPoint {
|
||||
sketch: u64,
|
||||
id: String,
|
||||
at: Vec2,
|
||||
},
|
||||
/// A sketch curve; `straight` for lines (usable as an axis).
|
||||
SketchCurve {
|
||||
sketch: u64,
|
||||
id: String,
|
||||
straight: bool,
|
||||
},
|
||||
Vertex {
|
||||
body: String,
|
||||
point: Vec3,
|
||||
},
|
||||
Edge {
|
||||
body: String,
|
||||
index: usize,
|
||||
mid: Vec3,
|
||||
},
|
||||
Profile {
|
||||
sketch: u64,
|
||||
index: usize,
|
||||
},
|
||||
Face {
|
||||
body: String,
|
||||
index: usize,
|
||||
point: Vec3,
|
||||
},
|
||||
/// An origin plane (XY, XZ, YZ) or a construction plane (by name).
|
||||
Plane {
|
||||
name: String,
|
||||
point: Vec3,
|
||||
},
|
||||
/// An origin axis (X, Y, Z).
|
||||
Axis {
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// World ↔ screen mapping for the current frame.
|
||||
@@ -99,16 +138,14 @@ fn visible_sketches(app: &SolveApp) -> Vec<u64> {
|
||||
fn scene_key(app: &SolveApp) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
app.session.revision.hash(&mut h);
|
||||
serde_json::to_string(&app.session.selection).unwrap_or_default().hash(&mut h);
|
||||
serde_json::to_string(&app.ui).unwrap_or_default().hash(&mut h);
|
||||
app.ui.show_grid.hash(&mut h);
|
||||
app.ui.show_sketches.hash(&mut h);
|
||||
app.ui.hidden_bodies.hash(&mut h);
|
||||
app.session.active_sketch.hash(&mut h);
|
||||
let (minor, _) = grid_step(app.cam.half_height());
|
||||
minor.to_bits().hash(&mut h);
|
||||
((app.cam.target.x / (minor * 5.0)).round() as i64).hash(&mut h);
|
||||
((app.cam.target.y / (minor * 5.0)).round() as i64).hash(&mut h);
|
||||
if let Some(d) = &app.dialog {
|
||||
d.highlight_key().hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
@@ -130,19 +167,11 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
sc.line((c + Vec3::new(-ext, t, 0.0)).to_f32(), (c + Vec3::new(ext, t, 0.0)).to_f32(), col, w, false);
|
||||
}
|
||||
}
|
||||
if app.ui.show_origin {
|
||||
let a = app.cam.half_height() * 0.3;
|
||||
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
|
||||
sc.line([0.0; 3], (d * a).to_f32(), rgba(col), 2.0, false);
|
||||
}
|
||||
}
|
||||
let dialog_edges: Vec<Vec3> = app.dialog.as_ref().map(|d| d.edges()).unwrap_or_default();
|
||||
for b in &st.bodies {
|
||||
if app.ui.hidden_bodies.contains(&b.name) {
|
||||
continue;
|
||||
}
|
||||
let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name));
|
||||
let col = rgba(if selected { colors::BODY_SELECTED } else { colors::BODY });
|
||||
let col = rgba(colors::BODY);
|
||||
let m = b.mesh();
|
||||
for t in &m.triangles {
|
||||
for k in t {
|
||||
@@ -156,18 +185,11 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
if m.seams.get(ei).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let sel = s.selection.iter().any(|x| matches!(x, Sel::Edge { body, index, .. } if *body == b.name && *index == ei))
|
||||
|| dialog_edges.iter().any(|p| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) < 1e-6 * b.body.size().max(1.0) + 1e-6));
|
||||
for w in e.windows(2) {
|
||||
if sel {
|
||||
sc.line(w[0].to_f32(), w[1].to_f32(), [30, 120, 230, 255], 3.5, true);
|
||||
} else {
|
||||
sc.line(w[0].to_f32(), w[1].to_f32(), rgba(colors::EDGE), 1.3, false);
|
||||
}
|
||||
sc.line(w[0].to_f32(), w[1].to_f32(), rgba(colors::EDGE), 1.3, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
let dialog_profiles = app.dialog.as_ref().map(|d| d.profiles()).unwrap_or_default();
|
||||
for sid in visible_sketches(app) {
|
||||
let Some(ss) = st.sketch(sid) else { continue };
|
||||
let active = s.active_sketch == Some(sid);
|
||||
@@ -176,34 +198,55 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
sc.line(w[0].to_f32(), w[1].to_f32(), rgba(col), if cons { 1.2 } else { 2.0 }, active);
|
||||
}
|
||||
}
|
||||
// Selected profiles: outline.
|
||||
for (pi, p) in ss.profiles.iter().enumerate() {
|
||||
let picked = dialog_profiles.contains(&(sid, pi))
|
||||
|| s.selection.iter().any(|x| matches!(x, Sel::Profile { sketch, index } if *sketch == sid && *index == pi));
|
||||
if !picked {
|
||||
continue;
|
||||
}
|
||||
for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) {
|
||||
let poly = lp.polyline(0.02);
|
||||
let n = poly.len();
|
||||
for i in 0..n {
|
||||
if let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) {
|
||||
sc.line(ss.plane.to_world(*a).to_f32(), ss.plane.to_world(*b).to_f32(), rgba(colors::PROFILE), 4.0, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sc
|
||||
}
|
||||
|
||||
/// Pick what is under `pos`, most specific first.
|
||||
/// Colour as straight (unpremultiplied) sRGBA bytes for the GPU.
|
||||
fn c4(c: Color32) -> [u8; 4] {
|
||||
c.to_srgba_unmultiplied()
|
||||
}
|
||||
|
||||
/// Origin planes and axes currently shown, sized for the view: (name, normal, quad).
|
||||
pub fn origin_planes(app: &SolveApp) -> Vec<(&'static str, Vec3, [Vec3; 4])> {
|
||||
if !app.origin_visible() {
|
||||
return Vec::new();
|
||||
}
|
||||
let creating = app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::Sketch));
|
||||
origin_quads(origin_size(app.cam.half_height()))
|
||||
.into_iter()
|
||||
.filter(|(n, _, _)| creating || !app.ui.hidden_origin.iter().any(|h| h == n))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn origin_axes(app: &SolveApp) -> Vec<(&'static str, Vec3)> {
|
||||
if !app.origin_visible() {
|
||||
return Vec::new();
|
||||
}
|
||||
[("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)].into_iter().filter(|(n, _)| !app.ui.hidden_origin.iter().any(|h| h == n)).collect()
|
||||
}
|
||||
|
||||
/// Construction planes drawn as squares around their origin: (name, normal, quad).
|
||||
pub fn construction_quads(app: &SolveApp) -> Vec<(String, Vec3, [Vec3; 4])> {
|
||||
let h = origin_size(app.cam.half_height()) * 1.2;
|
||||
solvecraft_engine::view::construction_planes(&app.session)
|
||||
.into_iter()
|
||||
.filter(|(_, n, _)| !app.ui.hidden_origin.contains(n))
|
||||
.map(|(_, name, pl)| {
|
||||
let (o, x, y) = (pl.origin, pl.x * h, pl.y * h);
|
||||
(name, pl.normal(), [o - x - y, o + x - y, o + x + y, o - x + y])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Pick what is under `pos`: sketch points, sketch curves, vertices, edges and axes first, then
|
||||
/// the surfaces along the ray (profiles, faces, planes) nearest first.
|
||||
pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||||
let s = &app.session;
|
||||
let st = s.model.state();
|
||||
let mut hits = Vec::new();
|
||||
let (o, d) = proj.ray(pos);
|
||||
// Sketch points and curves of the active sketch.
|
||||
// Sketch points of the active sketch.
|
||||
if let Some(sid) = s.active_sketch
|
||||
&& let Some(ss) = st.sketch(sid)
|
||||
{
|
||||
@@ -219,43 +262,29 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||||
if let Some((_, h)) = best {
|
||||
hits.push(h);
|
||||
}
|
||||
let mut bestc: Option<(f32, Hit)> = None;
|
||||
}
|
||||
// Sketch curves (the active sketch wins ties).
|
||||
let mut bestc: Option<(f32, Hit)> = None;
|
||||
for sid in visible_sketches(app) {
|
||||
let Some(ss) = st.sketch(sid) else { continue };
|
||||
let bonus = if s.active_sketch == Some(sid) { 2.0 } else { 0.0 };
|
||||
for (i, c) in ss.sketch.curves.iter().enumerate() {
|
||||
let straight = matches!(c.kind, CurveKind::Line { .. });
|
||||
for seg in ss.sketch.segs(i) {
|
||||
let pts: Vec<Pos2> = seg.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect();
|
||||
for w in pts.windows(2) {
|
||||
let dd = seg_dist(pos, w[0], w[1]);
|
||||
let dd = seg_dist(pos, w[0], w[1]) - bonus;
|
||||
if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) {
|
||||
bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: c.id.clone() }));
|
||||
bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: c.id.clone(), straight }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, h)) = bestc {
|
||||
hits.push(h);
|
||||
}
|
||||
}
|
||||
// Edges.
|
||||
let mut beste: Option<(f32, Hit)> = None;
|
||||
for b in &st.bodies {
|
||||
if app.ui.hidden_bodies.contains(&b.name) {
|
||||
continue;
|
||||
}
|
||||
let m = b.mesh();
|
||||
for (ei, e) in m.edges.iter().enumerate() {
|
||||
if m.seams.get(ei).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let pts: Vec<Pos2> = e.iter().filter_map(|q| proj.to_screen(*q)).collect();
|
||||
for w in pts.windows(2) {
|
||||
let dd = seg_dist(pos, w[0], w[1]);
|
||||
if dd < 6.0 && beste.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||||
beste = Some((dd, Hit::Edge { body: b.name.clone(), index: ei, mid: polyline_mid(e) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, h)) = bestc {
|
||||
hits.push(h);
|
||||
}
|
||||
// Faces (nearest along the ray), used to reject edges hidden behind faces.
|
||||
// Nearest face along the ray (hides what is behind it).
|
||||
let mut bestf: Option<(f64, Hit)> = None;
|
||||
for b in &st.bodies {
|
||||
if app.ui.hidden_bodies.contains(&b.name) {
|
||||
@@ -269,37 +298,342 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||||
bestf = Some((t, Hit::Face { body: b.name.clone(), index: fi, point: o + d * t }));
|
||||
}
|
||||
}
|
||||
if let Some((_, h)) = beste {
|
||||
// An edge counts only when it is not hidden behind the first face along the ray.
|
||||
let visible = match (&h, &bestf) {
|
||||
(Hit::Edge { mid, .. }, Some((t, _))) => (*mid - o).dot(d) <= *t + scene_radius(app) * 0.05,
|
||||
_ => true,
|
||||
};
|
||||
if visible {
|
||||
hits.push(h);
|
||||
let slack = scene_radius(app) * 0.02;
|
||||
let visible = |p: Vec3| bestf.as_ref().is_none_or(|(t, _)| (p - o).dot(d) <= *t + slack);
|
||||
// Vertices (edge end points) and edges.
|
||||
let mut bestv: Option<(f32, Hit)> = None;
|
||||
let mut beste: Option<(f32, Hit)> = None;
|
||||
for b in &st.bodies {
|
||||
if app.ui.hidden_bodies.contains(&b.name) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Profiles of visible sketches.
|
||||
let mut bestp: Option<(f64, Hit)> = None;
|
||||
for sid in visible_sketches(app) {
|
||||
let Some(ss) = st.sketch(sid) else { continue };
|
||||
let Some(w) = ss.plane.intersect_ray(o, d) else { continue };
|
||||
let lp = ss.plane.to_local(w);
|
||||
for (pi, p) in ss.profiles.iter().enumerate() {
|
||||
if p.region.contains(lp) && bestp.as_ref().is_none_or(|(a, _)| p.area < *a) {
|
||||
bestp = Some((p.area, Hit::Profile { sketch: sid, index: pi }));
|
||||
let m = b.mesh();
|
||||
for (ei, e) in m.edges.iter().enumerate() {
|
||||
if m.seams.get(ei).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
for v in [e.first(), e.last()].into_iter().flatten() {
|
||||
if let Some(sp) = proj.to_screen(*v) {
|
||||
let dd = sp.distance(pos);
|
||||
if dd < 7.0 && visible(*v) && bestv.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||||
bestv = Some((dd, Hit::Vertex { body: b.name.clone(), point: *v }));
|
||||
}
|
||||
}
|
||||
}
|
||||
let pts: Vec<Pos2> = e.iter().filter_map(|q| proj.to_screen(*q)).collect();
|
||||
for (k, w) in pts.windows(2).enumerate() {
|
||||
let dd = seg_dist(pos, w[0], w[1]);
|
||||
if dd < 6.0 && beste.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||||
let mid = polyline_mid(e);
|
||||
// Visible where the cursor is (not just at the middle).
|
||||
let at = e.get(k).copied().unwrap_or(mid);
|
||||
if visible(at) || visible(mid) {
|
||||
beste = Some((dd, Hit::Edge { body: b.name.clone(), index: ei, mid }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, h)) = bestp {
|
||||
hits.push(h);
|
||||
hits.extend(bestv.map(|x| x.1));
|
||||
hits.extend(beste.map(|x| x.1));
|
||||
// Origin axes.
|
||||
let size = origin_size(app.cam.half_height());
|
||||
let mut besta: Option<(f32, Hit)> = None;
|
||||
for (name, dir) in origin_axes(app) {
|
||||
if let (Some(a), Some(b)) = (proj.to_screen(Vec3::ZERO), proj.to_screen(dir * (size * AXIS_LEN))) {
|
||||
let dd = seg_dist(pos, a, b);
|
||||
if dd < 5.0 && besta.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||||
besta = Some((dd, Hit::Axis { name: name.into() }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, h)) = bestf {
|
||||
hits.push(h);
|
||||
hits.extend(besta.map(|x| x.1));
|
||||
// Surfaces by depth: profiles (slightly preferred over a face they lie on), faces, planes.
|
||||
let mut surf: Vec<(f64, Hit)> = Vec::new();
|
||||
let mut bestp: Option<(f64, f64, Hit)> = None;
|
||||
for sid in visible_sketches(app) {
|
||||
let Some(ss) = st.sketch(sid) else { continue };
|
||||
let n = ss.plane.normal();
|
||||
let den = n.dot(d);
|
||||
if den.abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let t = n.dot(ss.plane.origin - o) / den;
|
||||
if t < 0.0 {
|
||||
continue;
|
||||
}
|
||||
let lp = ss.plane.to_local(o + d * t);
|
||||
for (pi, p) in ss.profiles.iter().enumerate() {
|
||||
if p.region.contains(lp) && bestp.as_ref().is_none_or(|(_, a, _)| p.area < *a) {
|
||||
bestp = Some((t, p.area, Hit::Profile { sketch: sid, index: pi }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((t, _, h)) = bestp {
|
||||
surf.push((t - slack, h));
|
||||
}
|
||||
if let Some(f) = bestf {
|
||||
surf.push(f);
|
||||
}
|
||||
for (name, _, q) in origin_planes(app) {
|
||||
if let Some(t) = ray_quad(o, d, &q) {
|
||||
surf.push((t, Hit::Plane { name: name.into(), point: o + d * t }));
|
||||
}
|
||||
}
|
||||
for (name, _, q) in construction_quads(app) {
|
||||
if let Some(t) = ray_quad(o, d, &q) {
|
||||
surf.push((t, Hit::Plane { name, point: o + d * t }));
|
||||
}
|
||||
}
|
||||
surf.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
hits.extend(surf.into_iter().map(|x| x.1));
|
||||
hits
|
||||
}
|
||||
|
||||
/// Axis length as a multiple of the origin plane size (solid part 1, dashed beyond).
|
||||
const AXIS_LEN: f64 = 1.8;
|
||||
|
||||
/// The hit as a plain selection (no command filter).
|
||||
pub fn hit_sel(h: &Hit) -> Option<Sel> {
|
||||
Some(match h {
|
||||
Hit::SketchPoint { id, .. } => Sel::SketchPoint { id: id.clone() },
|
||||
Hit::SketchCurve { id, .. } => Sel::SketchCurve { id: id.clone() },
|
||||
Hit::Vertex { body, point } => Sel::Vertex { body: body.clone(), point: *point },
|
||||
Hit::Edge { body, index, mid } => Sel::Edge { body: body.clone(), index: *index, point: *mid },
|
||||
Hit::Profile { sketch, index } => Sel::Profile { sketch: *sketch, index: *index },
|
||||
Hit::Face { body, index, point } => Sel::Face { body: body.clone(), index: *index, point: *point },
|
||||
Hit::Plane { name, .. } => Sel::Plane { name: name.clone() },
|
||||
Hit::Axis { name } => Sel::Axis { name: name.clone() },
|
||||
})
|
||||
}
|
||||
|
||||
/// What a click would pick: the first hit the open dialog's active input accepts, or with no
|
||||
/// dialog the first hit (sketch entities only belong to the active sketch).
|
||||
pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> {
|
||||
if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) {
|
||||
return hits.iter().find_map(|h| d.candidate(&app.session, h).map(|s| (h.clone(), s)));
|
||||
}
|
||||
hits.iter()
|
||||
.filter(|h| match h {
|
||||
Hit::SketchCurve { sketch, .. } => app.session.active_sketch == Some(*sketch),
|
||||
_ => true,
|
||||
})
|
||||
.find_map(|h| hit_sel(h).map(|s| (h.clone(), s)))
|
||||
}
|
||||
|
||||
/// The edge a selection refers to now: by index if it still passes through the stored point,
|
||||
/// otherwise the edge nearest the point.
|
||||
fn edge_of(m: &solvecraft_engine::geom::Mesh, index: usize, point: Vec3) -> Option<&Vec<Vec3>> {
|
||||
let tol = m.bounds().diagonal().max(1.0) * 1e-6;
|
||||
let near = |e: &Vec<Vec3>| e.windows(2).map(|w| point.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||||
if let Some(e) = m.edges.get(index)
|
||||
&& near(e) < tol
|
||||
{
|
||||
return Some(e);
|
||||
}
|
||||
m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).filter(|e| near(e) < tol * 1e3)
|
||||
}
|
||||
|
||||
fn face_tris(sc: &mut GpuScene, m: &solvecraft_engine::geom::Mesh, face: usize, col: [u8; 4], lit: bool) {
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
if *f as usize != face {
|
||||
continue;
|
||||
}
|
||||
for k in t {
|
||||
let i = *k as usize;
|
||||
if let (Some(p), Some(n)) = (m.positions.get(i), m.normals.get(i)) {
|
||||
sc.tri(p.to_f32(), if lit { n.to_f32() } else { [0.0; 3] }, col);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn edge_lines(sc: &mut GpuScene, e: &[Vec3], core: Color32, halo: Color32, w: f32) {
|
||||
for (col, width) in [(halo, w + 3.0), (core, w)] {
|
||||
for p in e.windows(2) {
|
||||
sc.line(p[0].to_f32(), p[1].to_f32(), c4(col), width, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) {
|
||||
let st = app.session.model.state();
|
||||
let Some(ss) = st.sketch(sketch) else { return };
|
||||
let Some(p) = ss.profiles.get(index) else { return };
|
||||
// Lift toward the eye so a profile on a face shows over it.
|
||||
let lift = app.cam.back() * (app.cam.half_height() * 0.002);
|
||||
let w = |q: solvecraft_engine::geom::Vec2| (ss.plane.to_world(q) + lift).to_f32();
|
||||
let col = c4(fill);
|
||||
// In the translucent pass (after the grid and edges) so it covers them.
|
||||
for [a, b, c] in p.region.triangulate(0.02) {
|
||||
sc.trans_tri(w(a), [0.0; 3], col);
|
||||
sc.trans_tri(w(b), [0.0; 3], col);
|
||||
sc.trans_tri(w(c), [0.0; 3], col);
|
||||
}
|
||||
for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) {
|
||||
let poly = lp.polyline(0.02);
|
||||
let n = poly.len();
|
||||
for i in 0..n {
|
||||
if let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) {
|
||||
sc.line(w(*a), w(*b), c4(edge), 2.0, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn quad(sc: &mut GpuScene, q: &[Vec3; 4], fill: Color32, edge: Color32) {
|
||||
let col = c4(fill);
|
||||
for i in [0, 1, 2, 0, 2, 3] {
|
||||
if let Some(p) = q.get(i) {
|
||||
sc.trans_tri(p.to_f32(), [0.0; 3], col);
|
||||
}
|
||||
}
|
||||
for i in 0..4 {
|
||||
sc.line(q[i].to_f32(), q[(i + 1) % 4].to_f32(), c4(edge), 1.2, false);
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_key(app: &SolveApp) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
app.session.revision.hash(&mut h);
|
||||
serde_json::to_string(&app.highlighted()).unwrap_or_default().hash(&mut h);
|
||||
format!("{:?}", app.viewport.hover).hash(&mut h);
|
||||
app.origin_visible().hash(&mut h);
|
||||
app.ui.hidden_origin.hash(&mut h);
|
||||
app.ui.hidden_bodies.hash(&mut h);
|
||||
app.cam.half_height().to_bits().hash(&mut h);
|
||||
app.cam.back().x.to_bits().hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The highlight scene: origin widget, construction planes, selection and hover.
|
||||
fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
let t = Tokens::get();
|
||||
let mut sc = GpuScene::default();
|
||||
let st = app.session.model.state();
|
||||
let sel = app.highlighted();
|
||||
let hover = app.viewport.hover.as_ref();
|
||||
let size = origin_size(app.cam.half_height());
|
||||
// Origin planes and construction planes.
|
||||
let plane_state = |name: &str| {
|
||||
let selected = sel.iter().any(|x| matches!(x, Sel::Plane { name: n } if n == name));
|
||||
let hovered = matches!(hover, Some(Hit::Plane { name: n, .. }) if n == name);
|
||||
(selected, hovered)
|
||||
};
|
||||
let mut planes: Vec<(String, [Vec3; 4], Color32)> = origin_planes(app).into_iter().map(|(n, _, q)| (n.to_string(), q, t.origin_plane)).collect();
|
||||
planes.extend(construction_quads(app).into_iter().map(|(n, _, q)| (n, q, t.construction_plane)));
|
||||
for (name, q, base) in &planes {
|
||||
let (selected, hovered) = plane_state(name);
|
||||
let fill = if hovered {
|
||||
t.origin_plane_hover
|
||||
} else if selected {
|
||||
Color32::from_rgba_unmultiplied(t.sel_face.r(), t.sel_face.g(), t.sel_face.b(), 150)
|
||||
} else {
|
||||
*base
|
||||
};
|
||||
quad(&mut sc, q, fill, if selected { t.sel_edge } else { t.origin_plane_edge });
|
||||
}
|
||||
// Origin axes: solid near the origin, dashed beyond.
|
||||
for (name, dir) in origin_axes(app) {
|
||||
let col = match name {
|
||||
"X" => t.axis_x,
|
||||
"Y" => t.axis_y,
|
||||
_ => t.axis_z,
|
||||
};
|
||||
let on = sel.iter().any(|x| matches!(x, Sel::Axis { name: n } if n == name)) || matches!(hover, Some(Hit::Axis { name: n }) if n == name);
|
||||
let w = if on { 4.0 } else { 2.0 };
|
||||
sc.line([0.0; 3], (dir * size).to_f32(), c4(col), w, false);
|
||||
let dash = size * 0.08;
|
||||
let mut a = size + dash;
|
||||
while a < size * AXIS_LEN {
|
||||
sc.line((dir * a).to_f32(), (dir * (a + dash)).to_f32(), c4(col), w * 0.75, false);
|
||||
a += dash * 2.0;
|
||||
}
|
||||
}
|
||||
// Selected things.
|
||||
for x in &sel {
|
||||
match x {
|
||||
Sel::Face { body, index, .. } => {
|
||||
if let Some(b) = st.body(body) {
|
||||
face_tris(&mut sc, &b.mesh(), *index, c4(t.sel_face), false);
|
||||
}
|
||||
}
|
||||
Sel::Body { name } => {
|
||||
if let Some(b) = st.body(name)
|
||||
&& !app.ui.hidden_bodies.contains(name)
|
||||
{
|
||||
let m = b.mesh();
|
||||
for f in 0..b.body.face_count() {
|
||||
face_tris(
|
||||
&mut sc,
|
||||
&m,
|
||||
f,
|
||||
c4(Color32::from_rgb(colors::BODY_SELECTED.0, colors::BODY_SELECTED.1, colors::BODY_SELECTED.2)),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Sel::Edge { body, index, point } => {
|
||||
if let Some(b) = st.body(body)
|
||||
&& let Some(e) = edge_of(&b.mesh(), *index, *point)
|
||||
{
|
||||
edge_lines(&mut sc, e, t.sel_edge, t.sel_edge_rim, 3.0);
|
||||
}
|
||||
}
|
||||
Sel::Profile { sketch, index } => profile_fill(&mut sc, app, *sketch, *index, t.sel_profile, t.sel_profile_edge),
|
||||
Sel::SketchCurve { id } => {
|
||||
if let Some(ss) = app.session.active_sketch.and_then(|sid| st.sketch(sid))
|
||||
&& let Some(ci) = ss.sketch.curve_index(id)
|
||||
{
|
||||
for seg in ss.sketch.segs(ci) {
|
||||
let pts = seg.polyline(0.02);
|
||||
for w in pts.windows(2) {
|
||||
sc.line(ss.plane.to_world(w[0]).to_f32(), ss.plane.to_world(w[1]).to_f32(), c4(t.sel_edge), 3.5, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Hover (pre-highlight).
|
||||
match hover {
|
||||
Some(Hit::Face { body, index, .. }) if !sel.iter().any(|x| matches!(x, Sel::Face { body: b, index: i, .. } if b == body && i == index)) => {
|
||||
if let Some(b) = st.body(body) {
|
||||
let k = t.hover_face_lift;
|
||||
let c = colors::BODY;
|
||||
face_tris(&mut sc, &b.mesh(), *index, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
|
||||
let m = b.mesh();
|
||||
for e in m.face_edges(u32::try_from(*index).unwrap_or(u32::MAX)) {
|
||||
if let Some(p) = m.edges.get(e)
|
||||
&& !m.seams.get(e).copied().unwrap_or(false)
|
||||
{
|
||||
edge_lines(&mut sc, p, t.hover_edge, t.hover_edge_halo, 1.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Hit::Edge { body, index, mid }) => {
|
||||
if let Some(b) = st.body(body)
|
||||
&& let Some(e) = edge_of(&b.mesh(), *index, *mid)
|
||||
{
|
||||
let selected = sel.iter().any(|x| matches!(x, Sel::Edge { point, .. } if point.dist(*mid) < 1e-9));
|
||||
if !selected {
|
||||
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 2.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Hit::Profile { sketch, index })
|
||||
if !sel.iter().any(|x| matches!(x, Sel::Profile { sketch: s2, index: i2 } if s2 == sketch && i2 == index)) =>
|
||||
{
|
||||
profile_fill(&mut sc, app, *sketch, *index, t.hover_profile, t.hover_profile_edge);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
sc
|
||||
}
|
||||
|
||||
fn polyline_mid(pts: &[Vec3]) -> Vec3 {
|
||||
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let mut acc = 0.0;
|
||||
@@ -451,10 +785,20 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
app.viewport.key = key;
|
||||
app.viewport.build_ms = crate::now_ms() - t0;
|
||||
}
|
||||
let hk = highlight_key(app);
|
||||
if hk != app.viewport.hl_key {
|
||||
let hl = build_highlight(app);
|
||||
if let Ok(mut slot) = app.viewport.hl_slot.lock() {
|
||||
*slot = Some(hl);
|
||||
}
|
||||
app.viewport.hl_key = hk;
|
||||
}
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let cb = ViewportCallback {
|
||||
key: app.viewport.key,
|
||||
slot: app.viewport.slot.clone(),
|
||||
hl_key: app.viewport.hl_key,
|
||||
hl_slot: app.viewport.hl_slot.clone(),
|
||||
view_proj: proj.vp.to_f32(),
|
||||
back: proj.cam.back().to_f32(),
|
||||
size_px: [rect.width() * ppp, rect.height() * ppp],
|
||||
@@ -465,8 +809,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
|
||||
// ---- interaction ----
|
||||
let hits = hover.filter(|_| inside).map(|p| pick(app, &proj, p)).unwrap_or_default();
|
||||
app.viewport.hover = hits.first().cloned();
|
||||
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none()).map(|p| pick(app, &proj, p)).unwrap_or_default();
|
||||
let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) };
|
||||
app.viewport.hover = cand.as_ref().map(|c| c.0.clone());
|
||||
let add = mods.shift || mods.command || mods.ctrl;
|
||||
if let Some(p) = hover.filter(|_| inside) {
|
||||
if app.tool.is_some() {
|
||||
crate::tools::on_hover(app, &proj, p);
|
||||
@@ -475,19 +821,53 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if app.tool.is_some() {
|
||||
crate::tools::on_click(app, &proj, p);
|
||||
} else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) {
|
||||
if let Some(d) = app.dialog.as_mut() {
|
||||
d.on_pick(&hits);
|
||||
if let Some(sel) = cand.and_then(|c| c.1)
|
||||
&& let Some(mut d) = app.dialog.take()
|
||||
{
|
||||
d.pick(&app.session, sel);
|
||||
app.dialog = Some(d);
|
||||
}
|
||||
} else {
|
||||
select(app, &hits, mods.shift || mods.command);
|
||||
select(app, cand.and_then(|c| c.1), add);
|
||||
}
|
||||
}
|
||||
if resp.secondary_clicked() && delta == egui::Vec2::ZERO && app.tool.is_some() {
|
||||
crate::tools::finish(app);
|
||||
}
|
||||
}
|
||||
// Box selection: a primary drag on the model when no navigation mode is on.
|
||||
if app.tool.is_none() && app.viewport.nav.is_none() {
|
||||
let origin = ui.input(|i| i.pointer.press_origin());
|
||||
if resp.dragged_by(egui::PointerButton::Primary)
|
||||
&& let (Some(a), Some(b)) = (origin, hover)
|
||||
&& rect.contains(a)
|
||||
&& !cube.contains(a)
|
||||
{
|
||||
app.viewport.boxsel = Some(BoxSel::from_drag(a, b));
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
&& let Some(bx) = app.viewport.boxsel.take()
|
||||
{
|
||||
box_select(app, &proj, bx, add);
|
||||
}
|
||||
} else {
|
||||
app.viewport.boxsel = None;
|
||||
}
|
||||
overlays(app, ui, &painter, &proj);
|
||||
hover_highlight(app, &painter, &proj);
|
||||
points_2d(app, &painter, &proj);
|
||||
if let Some(bx) = app.viewport.boxsel {
|
||||
let col = if bx.crossing { t.box_crossing } else { t.box_window };
|
||||
painter.rect_filled(bx.rect, 0.0, col.gamma_multiply(0.12));
|
||||
if bx.crossing {
|
||||
let c = [bx.rect.left_top(), bx.rect.right_top(), bx.rect.right_bottom(), bx.rect.left_bottom()];
|
||||
for i in 0..4 {
|
||||
painter.add(Shape::dashed_line(&[c[i], c[(i + 1) % 4]], Stroke::new(1.2, col), 6.0, 4.0));
|
||||
}
|
||||
} else {
|
||||
painter.rect_stroke(bx.rect, 0.0, Stroke::new(1.2, col), egui::StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
if let Some(tl) = app.tool.as_ref() {
|
||||
crate::tools::preview(app, tl, &painter, &proj);
|
||||
}
|
||||
@@ -508,37 +888,137 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
fn select(app: &mut SolveApp, hits: &[Hit], add: bool) {
|
||||
let item = hits.first().map(|h| match h {
|
||||
Hit::SketchPoint { id, .. } => json!({"type": "sketch_point", "id": id}),
|
||||
Hit::SketchCurve { id, .. } => json!({"type": "sketch_curve", "id": id}),
|
||||
Hit::Edge { body, index, mid } => json!({"type": "edge", "body": body, "index": index, "point": mid}),
|
||||
Hit::Profile { sketch, index } => json!({"type": "profile", "sketch": sketch, "index": index}),
|
||||
Hit::Face { body, .. } => json!({"type": "body", "name": body}),
|
||||
});
|
||||
match item {
|
||||
Some(i) => {
|
||||
let _ = app.run("select.set", json!({"items": [i], "add": add}));
|
||||
/// A click without a command: select the candidate (replacing the selection, or toggling it
|
||||
/// in with Shift/Ctrl); a click on nothing clears.
|
||||
fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
|
||||
match sel {
|
||||
Some(x) => {
|
||||
let item = serde_json::to_value(&x).unwrap_or_default();
|
||||
if add && app.session.selection.contains(&x) {
|
||||
let rest: Vec<Sel> = app.session.selection.iter().filter(|y| **y != x).cloned().collect();
|
||||
let _ = app.run("select.set", json!({ "items": rest }));
|
||||
} else {
|
||||
let _ = app.run("select.set", json!({"items": [item], "add": add}));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
None if !add => {
|
||||
let _ = app.run("select.clear", json!({}));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
let col = Color32::from_rgb(40, 140, 255);
|
||||
/// What a box selects: sketch entities while sketching, otherwise what the open dialog's
|
||||
/// active input takes (edges, faces or bodies), otherwise bodies.
|
||||
fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
|
||||
use crate::selection::{BODIES, EDGES, FACES, PROFILES};
|
||||
let st = app.session.model.state();
|
||||
match &app.viewport.hover {
|
||||
Some(Hit::Edge { body, index, .. }) => {
|
||||
if let Some(b) = st.body(body)
|
||||
&& let Some(e) = b.mesh().edges.get(*index)
|
||||
{
|
||||
let pts: Vec<Pos2> = e.iter().filter_map(|q| proj.to_screen(*q)).collect();
|
||||
painter.add(Shape::line(pts, Stroke::new(3.0, col)));
|
||||
let mut out: Vec<Sel> = Vec::new();
|
||||
let to2 = |pts: &[Vec3]| -> Vec<Pos2> { pts.iter().filter_map(|p| proj.to_screen(*p)).collect() };
|
||||
if app.dialog.is_none()
|
||||
&& let Some(ss) = app.session.active_sketch.and_then(|sid| st.sketch(sid))
|
||||
{
|
||||
for (i, c) in ss.sketch.curves.iter().enumerate() {
|
||||
let pts: Vec<Vec3> = ss.sketch.segs(i).iter().flat_map(|sg| sg.polyline(0.05)).map(|q| ss.plane.to_world(q)).collect();
|
||||
if bx.takes(&to2(&pts)) {
|
||||
out.push(Sel::SketchCurve { id: c.id.clone() });
|
||||
}
|
||||
}
|
||||
Some(Hit::SketchCurve { sketch, id }) => {
|
||||
for p in &ss.sketch.points {
|
||||
if bx.takes(&to2(&[ss.plane.to_world(p.pos)])) {
|
||||
out.push(Sel::SketchPoint { id: p.id.clone() });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let accept = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).unwrap_or(BODIES);
|
||||
for b in &st.bodies {
|
||||
if app.ui.hidden_bodies.contains(&b.name) {
|
||||
continue;
|
||||
}
|
||||
let m = b.mesh();
|
||||
if accept & EDGES != 0 {
|
||||
for (ei, e) in m.edges.iter().enumerate() {
|
||||
if !m.seams.get(ei).copied().unwrap_or(false) && bx.takes(&to2(e)) {
|
||||
out.push(Sel::Edge { body: b.name.clone(), index: ei, point: polyline_mid(e) });
|
||||
}
|
||||
}
|
||||
} else if accept & FACES != 0 {
|
||||
for f in 0..b.body.face_count() {
|
||||
let mut pts: Vec<Vec3> = Vec::new();
|
||||
for e in m.face_edges(u32::try_from(f).unwrap_or(u32::MAX)) {
|
||||
pts.extend(m.edges.get(e).into_iter().flatten().copied());
|
||||
}
|
||||
let point = m
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.find(|(_, tf)| **tf as usize == f)
|
||||
.and_then(|(t, _)| m.tri(t))
|
||||
.map(|[a, bb, c]| (a + bb + c) / 3.0);
|
||||
if let Some(point) = point
|
||||
&& bx.takes(&to2(&pts))
|
||||
{
|
||||
out.push(Sel::Face { body: b.name.clone(), index: f, point });
|
||||
}
|
||||
}
|
||||
} else if accept & BODIES != 0 {
|
||||
let crossing_hit = m.edges.iter().any(|e| bx.takes(&to2(e)));
|
||||
let all_in = m.edges.iter().all(|e| bx.takes(&to2(e)));
|
||||
if (bx.crossing && crossing_hit) || (!bx.crossing && all_in && !m.edges.is_empty()) {
|
||||
out.push(Sel::Body { name: b.name.clone() });
|
||||
}
|
||||
} else if accept & PROFILES != 0 {
|
||||
for sid in visible_sketches(app) {
|
||||
let Some(ss) = st.sketch(sid) else { continue };
|
||||
for (pi, p) in ss.profiles.iter().enumerate() {
|
||||
let pts: Vec<Vec3> = p.region.outer.polyline(0.05).into_iter().map(|q| ss.plane.to_world(q)).collect();
|
||||
if bx.takes(&to2(&pts)) {
|
||||
out.push(Sel::Profile { sketch: sid, index: pi });
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mut d) = app.dialog.take() {
|
||||
d.take_box(out, add);
|
||||
app.dialog = Some(d);
|
||||
return;
|
||||
}
|
||||
let items = serde_json::to_value(&out).unwrap_or_default();
|
||||
let _ = app.run("select.set", json!({"items": items, "add": add}));
|
||||
}
|
||||
|
||||
/// Screen-space dots: the origin point, and hovered or selected body vertices.
|
||||
fn points_2d(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
let t = Tokens::get();
|
||||
if app.origin_visible()
|
||||
&& !app.ui.hidden_origin.iter().any(|h| h == "O")
|
||||
&& let Some(o) = proj.to_screen(Vec3::ZERO)
|
||||
{
|
||||
painter.circle(o, 4.5, t.origin_point, Stroke::new(1.0, Color32::from_gray(110)));
|
||||
}
|
||||
for x in app.highlighted() {
|
||||
if let Sel::Vertex { point, .. } = x
|
||||
&& let Some(p) = proj.to_screen(point)
|
||||
{
|
||||
painter.circle(p, 4.0, t.sel_vertex, Stroke::new(1.5, t.sel_edge));
|
||||
}
|
||||
}
|
||||
if let Some(Hit::Vertex { point, .. }) = &app.viewport.hover
|
||||
&& let Some(p) = proj.to_screen(*point)
|
||||
{
|
||||
painter.circle(p, 4.5, t.hover_edge_halo, Stroke::new(1.5, t.hover_edge));
|
||||
}
|
||||
}
|
||||
|
||||
/// Hover feedback for the active sketch's points and curves (drawn on top of everything).
|
||||
fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
let col = Tokens::get().hover_profile_edge;
|
||||
let st = app.session.model.state();
|
||||
match &app.viewport.hover {
|
||||
Some(Hit::SketchCurve { sketch, id, .. }) => {
|
||||
if let Some(ss) = st.sketch(*sketch)
|
||||
&& let Some(ci) = ss.sketch.curve_index(id)
|
||||
{
|
||||
@@ -555,14 +1035,6 @@ fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
painter.circle_stroke(p, 6.0, Stroke::new(2.0, col));
|
||||
}
|
||||
}
|
||||
Some(Hit::Profile { sketch, index }) if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) => {
|
||||
if let Some(ss) = st.sketch(*sketch)
|
||||
&& let Some(p) = ss.profiles.get(*index)
|
||||
{
|
||||
let pts: Vec<Pos2> = p.region.outer.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect();
|
||||
painter.add(Shape::closed_line(pts, Stroke::new(2.0, Color32::from_rgb(255, 170, 60))));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,10 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints
|
||||
| `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `hiddenBodies`, …) | |
|
||||
| `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | |
|
||||
| `ui.start` | `command` | like a toolbar click: starts the sketch tool or dialog for the command |
|
||||
| `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?` | real pointer input |
|
||||
| `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?` | real pointer input |
|
||||
| `ui.move` | `x`, `y` | |
|
||||
| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing) |
|
||||
| `ui.selection` | | the selection, the open dialog's inputs and the hovered item |
|
||||
| `ui.scroll` | `x`, `y`, `delta?` | wheel zoom at the cursor |
|
||||
| `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | |
|
||||
| `ui.text` | `text` | typed text |
|
||||
|
||||
+9
-9
@@ -2,12 +2,12 @@
|
||||
|
||||
Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolveCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth.
|
||||
|
||||
**SOLID + SKETCH: 53 / 291 commands live (18%).** All Design tabs: 129 / 1175 (11%). Registered commands: 74.
|
||||
**SOLID + SKETCH: 57 / 291 commands live (20%).** All Design tabs: 137 / 1175 (12%). Registered commands: 78.
|
||||
|
||||
| Tab | Panel | Live | Total | % |
|
||||
|---|---|---:|---:|---:|
|
||||
| SOLID | CREATE | 11 | 35 | 31% |
|
||||
| SOLID | MODIFY | 8 | 32 | 25% |
|
||||
| SOLID | CREATE | 13 | 35 | 37% |
|
||||
| SOLID | MODIFY | 10 | 32 | 31% |
|
||||
| SOLID | ASSEMBLE | 0 | 21 | 0% |
|
||||
| SOLID | CONFIGURE | 0 | 3 | 0% |
|
||||
| SOLID | CONSTRUCT | 2 | 20 | 10% |
|
||||
@@ -65,8 +65,8 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
|
||||
| SHEET METAL | REFOLD | 0 | 1 | 0% |
|
||||
| SHEET METAL | POSITION | 0 | 2 | 0% |
|
||||
| PLASTIC | SETUP | 0 | 2 | 0% |
|
||||
| PLASTIC | CREATE | 11 | 35 | 31% |
|
||||
| PLASTIC | MODIFY | 8 | 32 | 25% |
|
||||
| PLASTIC | CREATE | 13 | 35 | 37% |
|
||||
| PLASTIC | MODIFY | 10 | 32 | 31% |
|
||||
| PLASTIC | ASSEMBLE | 0 | 20 | 0% |
|
||||
| PLASTIC | CONFIGURE | 0 | 3 | 0% |
|
||||
| PLASTIC | CONSTRUCT | 2 | 20 | 10% |
|
||||
@@ -105,6 +105,8 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
|
||||
- SOLID › CREATE › Create Sketch (`SketchCreate`)
|
||||
- SOLID › CREATE › Extrude (`Extrude`)
|
||||
- SOLID › CREATE › Revolve (`Revolve`)
|
||||
- SOLID › CREATE › Sweep (`Sweep`)
|
||||
- SOLID › CREATE › Loft (`SolidLoft`)
|
||||
- SOLID › CREATE › Hole (`FusionHoleCommand`)
|
||||
- SOLID › CREATE › Box (`PrimitiveBox`)
|
||||
- SOLID › CREATE › Cylinder (`PrimitiveCylinder`)
|
||||
@@ -115,6 +117,8 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
|
||||
- SOLID › CREATE › Mirror (`MirrorCommand`)
|
||||
- SOLID › MODIFY › Fillet (`FusionFilletEdgesCommand`)
|
||||
- SOLID › MODIFY › Chamfer (`FusionChamferCommand`)
|
||||
- SOLID › MODIFY › Shell (`FusionShellBodyCommand`)
|
||||
- SOLID › MODIFY › Draft (`FusionDraftCommand`)
|
||||
- SOLID › MODIFY › Combine (`FusionCombineCommand`)
|
||||
- SOLID › MODIFY › Split Body (`FusionSplitBodyCommand`)
|
||||
- SOLID › MODIFY › Move/Copy (`FusionMoveCommand`)
|
||||
@@ -161,8 +165,6 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
|
||||
- SOLID › CREATE › Create Form (`TSplineBaseFeatureCreationCommand`)
|
||||
- SOLID › CREATE › Derive (`PushDeriveCommand`)
|
||||
- SOLID › CREATE › Automated Modeling (`AutomatedModelingCommand`)
|
||||
- SOLID › CREATE › Sweep (`Sweep`)
|
||||
- SOLID › CREATE › Loft (`SolidLoft`)
|
||||
- SOLID › CREATE › Rib (`FusionRibCommand`)
|
||||
- SOLID › CREATE › Web (`FusionWebCommand`)
|
||||
- SOLID › CREATE › Emboss (`EmbossCmd`)
|
||||
@@ -184,8 +186,6 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
|
||||
- SOLID › CREATE › Generate Timeline (`TimelineBuilderCommand`)
|
||||
- SOLID › MODIFY › Press Pull (`FusionPressPullCommand`)
|
||||
- SOLID › MODIFY › Edit Face (`EditFaceCommand`)
|
||||
- SOLID › MODIFY › Shell (`FusionShellBodyCommand`)
|
||||
- SOLID › MODIFY › Draft (`FusionDraftCommand`)
|
||||
- SOLID › MODIFY › Scale (`ModifyScale`)
|
||||
- SOLID › MODIFY › Offset Face (`FusionOffsetFacesCommand`)
|
||||
- SOLID › MODIFY › Replace Face (`FusionReplaceFaceCommand`)
|
||||
|
||||
Reference in new issue
Block a user