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:
bherbruckandClaude Opus 5.5 committed 2026-10-07 20:00:17 +00:00
1 parent 059909fb05
commit 97e73b0935
20 files changed
+1907 -518

No files matched your search

+8 -6
View File
@@ -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
+13
View File
@@ -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.
+24
View File
@@ -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()));
}
+14
View File
@@ -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()
}
+50
View File
@@ -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)?])
}
+52
View File
@@ -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)]);
+22 -1
View File
@@ -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]);
}
+15 -2
View File
@@ -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)
})
}
+1
View File
@@ -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 };
+49 -9
View File
@@ -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 {
+26 -1
View File
@@ -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)));
File diff suppressed because it is too large. Load diff
+79 -34
View File
@@ -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);
}
}
+4
View File
@@ -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);
+42
View File
@@ -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));
}
+234
View File
@@ -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() }]);
}
}
+53
View File
@@ -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
View File
@@ -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))));
}
}
_ => {}
}
}
+3 -1
View File
@@ -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
View File
@@ -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`)