diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 303a1520..7ab92060 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2286,14 +2286,13 @@ fn curves_sketch(app: &SolveApp, ids: &[String], not: Option) -> Option Option<(Vec3, Vec3)> { match sel { - Sel::Axis { name } => Some(( - Vec3::ZERO, - match name.as_str() { - "X" => Vec3::X, - "Y" => Vec3::Y, - _ => Vec3::Z, - }, - )), + Sel::Axis { name } => match name.as_str() { + "X" => Some((Vec3::ZERO, Vec3::X)), + "Y" => Some((Vec3::ZERO, Vec3::Y)), + "Z" => Some((Vec3::ZERO, Vec3::Z)), + // A construction axis. + n => solvecraft_engine::view::construction_axes(&app.session).into_iter().find(|(_, an, _, _)| an == n).map(|(_, _, o, d)| (o, d)), + }, Sel::SketchCurve { id } => { let st = app.session.world_state(); let sid = curves_sketch(app, std::slice::from_ref(id), None)?; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0fa257d7..c0d6ad26 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -60,6 +60,8 @@ pub struct Tokens { pub axis_z: Color32, /// Construction planes. pub construction_plane: Color32, + /// Construction axes and points. + pub construction_geom: Color32, // ---- bodies and live previews ---- pub body: Color32, pub body_edge: Color32, @@ -164,6 +166,7 @@ impl Tokens { 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), + construction_geom: Color32::from_rgb(232, 140, 48), body: Color32::from_rgb(176, 186, 198), body_edge: Color32::from_rgb(40, 44, 52), manipulator: Color32::from_rgb(40, 110, 220), diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 7d18d71c..96cc3c05 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -23,6 +23,8 @@ fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::Pattern { .. } => "pattern_circ", FeatureKind::Mirror { .. } => "mirror", FeatureKind::ConstructionPlane { .. } => "plane", + FeatureKind::ConstructionAxis { .. } => "axis", + FeatureKind::ConstructionPoint { .. } => "point", FeatureKind::Hole { .. } => "hole", FeatureKind::Loft { .. } => "loft", FeatureKind::Sweep { .. } => "sweep", diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 3d840d30..f73f82cc 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -404,6 +404,43 @@ pub fn origin_axes(app: &SolveApp) -> Vec<(&'static str, Vec3)> { [("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)].into_iter().filter(|(n, _)| !app.ui.hidden_origin.iter().any(|h| h == n)).collect() } +/// Construction axes shown, as segments: across the model where the axis passes it (a little +/// past it either side), at least the origin axes' length around the axis's origin: (name, +/// from, to). +pub fn construction_axis_segments(app: &SolveApp) -> Vec<(String, Vec3, Vec3)> { + let h = origin_size(app.cam.half_height()) * AXIS_LEN; + let bb = solvecraft_engine::view::bounds(&app.session); + solvecraft_engine::view::construction_axes(&app.session) + .into_iter() + .filter(|(_, n, _, _)| !app.ui.hidden_origin.contains(n)) + .map(|(_, name, o, d)| { + let (mut lo, mut hi) = (-h, h); + if !bb.is_empty() { + for i in 0..8 { + let c = Vec3::new( + if i & 1 == 0 { bb.min.x } else { bb.max.x }, + if i & 2 == 0 { bb.min.y } else { bb.max.y }, + if i & 4 == 0 { bb.min.z } else { bb.max.z }, + ); + let t = (c - o).dot(d); + lo = lo.min(t - h * 0.3); + hi = hi.max(t + h * 0.3); + } + } + (name, o + d * lo, o + d * hi) + }) + .collect() +} + +/// Construction points shown: (name, position). +pub fn construction_points(app: &SolveApp) -> Vec<(String, Vec3)> { + solvecraft_engine::view::construction_points(&app.session) + .into_iter() + .filter(|(_, n, _)| !app.ui.hidden_origin.contains(n)) + .map(|(_, n, p)| (n, p)) + .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; @@ -648,6 +685,15 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { } } } + // Construction axes, picked like the origin axes. + for (name, a, b) in construction_axis_segments(app) { + if let (Some(a), Some(b)) = (proj.to_screen(a), proj.to_screen(b)) { + 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 })); + } + } + } 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(); @@ -971,6 +1017,27 @@ fn build_highlight(app: &SolveApp) -> GpuScene { a += dash * 2.0; } } + // Construction axes (dashed, the length of the origin axes either side) and points (a + // small star), in the construction colour. + for (name, a, b) in construction_axis_segments(app) { + 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 col = c4(if on { t.sel_edge } else { t.construction_geom }); + let w = if on { 3.0 } else { 1.6 }; + let len = a.dist(b); + let dash = size * 0.08; + let mut s = 0.0; + while s < len { + let e = (s + dash).min(len); + sc.line(a.lerp(b, s / len).to_f32(), a.lerp(b, e / len).to_f32(), col, w, false); + s += dash * 1.6; + } + } + for (_, p) in construction_points(app) { + let r = size * 0.04; + for d in [Vec3::X, Vec3::Y, Vec3::Z] { + sc.line((p - d * r).to_f32(), (p + d * r).to_f32(), c4(t.construction_geom), 2.0, true); + } + } // Selected things. for x in &sel { match x { @@ -2113,6 +2180,30 @@ mod tests { assert_eq!(regions, 26); } + #[test] + fn construction_axes_span_the_model_and_points_show() { + let mut s = solvecraft_engine::Session::default(); + s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); + s.execute("construct.axis.two_points", &json!({"a": {"vertex": [0, 0, 20]}, "b": {"vertex": [40, 30, 20]}})).unwrap(); + s.execute("construct.point.vertex", &json!({"point": {"vertex": [40, 0, 20]}})).unwrap(); + let mut app = SolveApp::new(s, crate::Services::default()); + let axes = construction_axis_segments(&app); + assert_eq!(axes.len(), 1); + let (name, a, b) = axes[0].clone(); + // The segment runs through both points and past them. + let d = (b - a).normalized().unwrap(); + for p in [Vec3::new(0.0, 0.0, 20.0), Vec3::new(40.0, 30.0, 20.0)] { + let t = (p - a).dot(d); + assert!(t > 0.0 && t < a.dist(b) && (a + d * t).dist(p) < 1e-6, "{p:?} not on {a:?}..{b:?}"); + } + assert_eq!(construction_points(&app).len(), 1); + // It is an axis for dialogs (pattern directions, revolve axes), and hiding it hides it. + let (o, dir) = crate::dialogs::axis_of(&app, &Sel::Axis { name: name.clone() }).unwrap(); + assert!(o.dist(Vec3::new(0.0, 0.0, 20.0)) < 1e-6 && dir.dist(d) < 1e-6); + app.ui.hidden_origin.push(name); + assert!(construction_axis_segments(&app).is_empty()); + } + #[test] fn view_cube_zones() { // A square face 100 px wide, corners in order −−, +−, ++, −+ (screen y down).