Fixes #971. At the node limit the offset certificate splits the fitted curve where it meets the exact offset midpoint, so curves with different parameter speeds certify within the same 0.002 mm tolerance. Associated offset fit nodes are hidden from display, picking, box selection and snapping until Break Association. Thanks @Karl-VonRichter.
4659 lines
216 KiB
Rust
4659 lines
216 KiB
Rust
//! The 3D viewport: navigation, GPU (or CPU fallback) rendering, picking, overlays, the axis
|
||
//! view and the navigation bar.
|
||
|
||
use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2};
|
||
use serde_json::json;
|
||
use solidcraft_engine::Sel;
|
||
use solidcraft_engine::geom::{Vec2, Vec3};
|
||
use solidcraft_engine::render::{Camera, Mat4, Rgb};
|
||
use solidcraft_engine::sketch::CurveKind;
|
||
use solidcraft_engine::view::{colors, grid_step, sketch_lines_with_sampler};
|
||
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};
|
||
|
||
#[cfg(test)]
|
||
#[path = "viewport_appearance_tests.rs"]
|
||
mod appearance_tests;
|
||
|
||
#[cfg(test)]
|
||
#[path = "viewport_plane_target_tests.rs"]
|
||
mod plane_target_tests;
|
||
|
||
#[path = "navigation_cube.rs"]
|
||
mod navigation_cube;
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum NavMode {
|
||
Orbit,
|
||
Pan,
|
||
Zoom,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct ViewportState {
|
||
/// Per-body boxes of one world state, held (not just its address, which a later state can
|
||
/// reuse once the old one is freed, so stale boxes would come back).
|
||
pick_bounds: std::sync::Mutex<Option<BodyBounds>>,
|
||
/// The last click in the view was the middle button's (for the middle double-click).
|
||
middle_clicked: bool,
|
||
/// The last pick (its key and hits): reused while nothing it depends on changed.
|
||
last_pick: Option<(u64, Vec<Hit>)>,
|
||
pub rect: Option<Rect>,
|
||
pub(crate) layout: Option<crate::viewport_layout::Layout>,
|
||
pub(crate) palette_rect: Option<Rect>,
|
||
pub(crate) dialog_rect: Option<Rect>,
|
||
pub gpu: Option<GpuTarget>,
|
||
slot: SceneSlot,
|
||
key: u64,
|
||
sketch_slot: SceneSlot,
|
||
sketch_key: u64,
|
||
physical_scale: f32,
|
||
pub(crate) display: std::sync::Mutex<crate::sketch_display::DisplayCache>,
|
||
hl_slot: SceneSlot,
|
||
hl_key: u64,
|
||
cpu: Option<(u64, egui::TextureHandle)>,
|
||
/// Nothing will look at the frame (the headless scenario harness): skip the software render.
|
||
pub no_pixels: bool,
|
||
pub nav: Option<NavMode>,
|
||
/// The preset's navigation for the left-button drag under way (Alias: Shift+Alt), taken
|
||
/// when the button went down, so the drag neither selects nor moves sketch geometry.
|
||
pub primary_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>,
|
||
/// Timeline item under the cursor: its bodies or resulting faces are highlighted.
|
||
pub hover_feature: Option<u64>,
|
||
/// Bodies whose browser row is under the cursor: highlighted the same way.
|
||
pub hover_bodies: Vec<String>,
|
||
/// The model point an orbit turns around (Preferences: orbit around the cursor).
|
||
pub orbit_pivot: Option<Vec3>,
|
||
/// Continuous orbit: the view turns about the vertical until the next click or key.
|
||
pub spin: bool,
|
||
/// After immediate zoom, ignore egui's outstanding smoothing until the gesture ends.
|
||
wheel_zoom_raw_until_idle: bool,
|
||
/// While trackpad scrolling runs, ignore egui's smoothing tail for zoom so
|
||
/// two-finger input is not replayed as zoom after it pans.
|
||
trackpad_until_idle: bool,
|
||
/// Inside a bracketed trackpad scroll (`Start` without its `End` yet):
|
||
/// point moves pan, bare moves are a mouse.
|
||
trackpad_gesture: bool,
|
||
/// A primary press on the axis gizmo: the pointer position used by its last orbit step.
|
||
pub axis_drag: Option<Pos2>,
|
||
/// Unsnapped camera while Shift-orbit previews a standard or diagonal view.
|
||
pub orbit_free: Option<Camera>,
|
||
/// Spring toward the chosen orientation, including after the drag ends.
|
||
pub(crate) orbit_snap: Option<OrbitSnap>,
|
||
pub build_ms: f64,
|
||
/// The right-click menu, open at this screen position.
|
||
pub context_menu: Option<Pos2>,
|
||
/// Select Other: its list, a press being held, and click cycling.
|
||
pub select_other: crate::select_other::State,
|
||
/// A sketch point or curve being dragged (its id, the undo depth when the drag began: the
|
||
/// whole drag is one undo step, and for a curve where it was last under the cursor).
|
||
pub point_drag: Option<(String, usize, Option<Vec2>)>,
|
||
/// Checkpoint and source for a cancellable whole-text drag.
|
||
pub(crate) text_drag: Option<(u64, String)>,
|
||
}
|
||
|
||
/// Something under the cursor.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub enum Hit {
|
||
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,
|
||
},
|
||
}
|
||
|
||
impl ViewportState {
|
||
pub fn pixels_per_point(&self) -> f32 {
|
||
if self.physical_scale > 0.0 { self.physical_scale } else { 1.0 }
|
||
}
|
||
#[cfg(test)]
|
||
pub(crate) fn set_pixels_per_point(&mut self, scale: f32) {
|
||
self.physical_scale = scale;
|
||
}
|
||
}
|
||
|
||
fn display_proj(app: &SolveApp) -> Proj {
|
||
projection(app, app.viewport.rect.unwrap_or_else(|| Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))))
|
||
}
|
||
|
||
/// World ↔ screen mapping for the current frame.
|
||
#[derive(Clone, Copy)]
|
||
pub struct Proj {
|
||
pub cam: Camera,
|
||
pub vp: Mat4,
|
||
pub rect: Rect,
|
||
}
|
||
|
||
impl Proj {
|
||
pub fn to_screen(&self, p: Vec3) -> Option<Pos2> {
|
||
let c = self.vp.apply(p);
|
||
if c[3] <= 1e-9 {
|
||
return None;
|
||
}
|
||
let (x, y) = (c[0] / c[3], c[1] / c[3]);
|
||
Some(pos2(
|
||
self.rect.left() + ((x + 1.0) * 0.5 * self.rect.width() as f64) as f32,
|
||
self.rect.top() + ((1.0 - y) * 0.5 * self.rect.height() as f64) as f32,
|
||
))
|
||
}
|
||
pub fn ray(&self, p: Pos2) -> (Vec3, Vec3) {
|
||
self.cam.ray((p.x - self.rect.left()) as f64, (p.y - self.rect.top()) as f64, self.rect.width() as f64, self.rect.height() as f64)
|
||
}
|
||
}
|
||
|
||
/// A projected 3D face (convex): an unfeathered triangle fan, outlined one edge at a time (no
|
||
/// joins between the edges). Unlike `Shape::convex_polygon`, it stays inside its outline when
|
||
/// the face turns edge-on.
|
||
pub(crate) fn projected_polygon(pts: &[Pos2], fill: Color32, stroke: Stroke) -> Vec<Shape> {
|
||
let mut mesh = egui::Mesh::default();
|
||
for (i, p) in (0u32..).zip(pts) {
|
||
mesh.colored_vertex(*p, fill);
|
||
if i >= 2 {
|
||
mesh.add_triangle(0, i - 1, i);
|
||
}
|
||
}
|
||
let mut out = vec![Shape::mesh(mesh)];
|
||
out.extend(pts.iter().zip(pts.iter().skip(1).chain(pts.first())).map(|(a, b)| Shape::line_segment([*a, *b], stroke)));
|
||
out
|
||
}
|
||
|
||
/// View directions all round, a few degrees apart, none exactly along an axis.
|
||
#[cfg(test)]
|
||
pub(crate) fn orbit_directions() -> impl Iterator<Item = Vec3> {
|
||
(0..90).flat_map(|i| {
|
||
(0..45).map(move |j| {
|
||
let (yaw, pitch) = (f64::from(i) * 4.0f64.to_radians() + 0.003, f64::from(j) * 4.0f64.to_radians() - 1.5);
|
||
Vec3::new(pitch.cos() * yaw.cos(), pitch.cos() * yaw.sin(), pitch.sin())
|
||
})
|
||
})
|
||
}
|
||
|
||
/// Every vertex egui draws for `shapes`.
|
||
#[cfg(test)]
|
||
pub(crate) fn drawn_points(shapes: Vec<Shape>) -> Vec<Pos2> {
|
||
let mut tess = egui::epaint::Tessellator::new(1.0, egui::epaint::TessellationOptions::default(), [1, 1], vec![]);
|
||
let mut mesh = egui::Mesh::default();
|
||
for shape in shapes {
|
||
tess.tessellate_shape(shape, &mut mesh);
|
||
}
|
||
mesh.vertices.iter().map(|v| v.pos).collect()
|
||
}
|
||
|
||
fn rgba(c: Rgb) -> [u8; 4] {
|
||
[c.0, c.1, c.2, 255]
|
||
}
|
||
|
||
pub fn scene_radius(app: &SolveApp) -> f64 {
|
||
let mut b = model_bounds(app);
|
||
if !app.preview.bounds.is_empty() {
|
||
b = b.union(&app.preview.bounds);
|
||
}
|
||
let r = if b.is_empty() { 50.0 } else { b.diagonal() * 0.5 + b.center().dist(app.cam.target) };
|
||
r.max(app.cam.half_height() * 2.0).max(10.0)
|
||
}
|
||
|
||
pub fn projection(app: &SolveApp, rect: Rect) -> Proj {
|
||
let mut cam = app.cam;
|
||
cam.fov = if app.ui.perspective { 0.6 } else { 0.0 };
|
||
let aspect = rect.width() as f64 / rect.height().max(1.0) as f64;
|
||
Proj { cam, vp: cam.view_proj(aspect, scene_radius(app)), rect }
|
||
}
|
||
|
||
/// Sketches drawn in the viewport: the active one, and finished ones not yet used by a feature.
|
||
fn visible_sketches(app: &SolveApp) -> Vec<u64> {
|
||
let st = app.session.world_state();
|
||
st.sketches.iter().filter(|s| solidcraft_engine::view::sketch_drawn(&app.session, s.feature, app.ui.show_sketches)).map(|s| s.feature).collect()
|
||
}
|
||
|
||
/// What Home, Fit and the orbit pivot frame: the bodies and sketches shown now, where they are
|
||
/// now (see [`solidcraft_engine::view::visible_bounds`]).
|
||
pub fn view_bounds(app: &SolveApp) -> solidcraft_engine::geom::Aabb3 {
|
||
solidcraft_engine::view::visible_bounds(&app.session, app.ui.show_sketches)
|
||
}
|
||
|
||
fn scene_key(app: &SolveApp) -> u64 {
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
app.session.revision.hash(&mut h);
|
||
(std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h);
|
||
app.ui.show_grid.hash(&mut h);
|
||
app.ui.visual_style.hash(&mut h);
|
||
app.ui.ground_shadow.hash(&mut h);
|
||
// The section plane (its cut outlines are part of the model scene).
|
||
section_plane(app).map(|(o, n)| [o.x, o.y, o.z, n.x, n.y, n.z].map(f64::to_bits)).hash(&mut h);
|
||
app.cam.up.hash(&mut h);
|
||
(app.cam.pitch > 0.0).hash(&mut h);
|
||
app.ui.show_sketches.hash(&mut h);
|
||
app.session.visibility.hidden_bodies.hash(&mut h);
|
||
app.session.visibility.hidden_occurrences.hash(&mut h);
|
||
app.session.visibility.hidden_sketches.hash(&mut h);
|
||
app.session.visibility.shown_sketches.hash(&mut h);
|
||
app.ui.hidden_profiles.hash(&mut h);
|
||
app.session.active_component.hash(&mut h);
|
||
app.session.active_occurrence_path.hash(&mut h);
|
||
app.preview.replaced.hash(&mut h);
|
||
app.tree.redefine.hash(&mut h);
|
||
crate::theme::is_dark().hash(&mut h);
|
||
app.session.active_sketch.hash(&mut h);
|
||
h.finish()
|
||
}
|
||
|
||
fn sketch_scene_key(app: &SolveApp, proj: &Proj) -> u64 {
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
scene_key(app).hash(&mut h);
|
||
(std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h);
|
||
crate::sketch_display::projection_key(proj, app.viewport.pixels_per_point()).hash(&mut h);
|
||
h.finish()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn display_scene_keys(app: &SolveApp, proj: &Proj) -> (u64, u64) {
|
||
(scene_key(app), sketch_scene_key(app, proj))
|
||
}
|
||
|
||
/// A sketch curve colour from the engine, in the current theme.
|
||
fn sketch_color(t: &Tokens, c: Rgb) -> [u8; 4] {
|
||
if c == colors::SKETCH {
|
||
c4(t.sketch)
|
||
} else if c == colors::SKETCH_DONE || c == colors::SKETCH_FIXED {
|
||
c4(t.sketch_fixed)
|
||
} else if c == colors::SKETCH_LOCKED {
|
||
c4(t.sketch_locked)
|
||
} else {
|
||
rgba(c)
|
||
}
|
||
}
|
||
|
||
fn build_scene(app: &SolveApp) -> GpuScene {
|
||
let tk = Tokens::get();
|
||
let mut sc = GpuScene::default();
|
||
let s = &app.session;
|
||
// Bodies where their occurrences place them.
|
||
let st = s.world_state();
|
||
let active_bodies = s.active_occurrence_path.as_ref().and_then(|p| s.occurrence_bodies(p, true).ok());
|
||
for b in &st.bodies {
|
||
if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) {
|
||
continue;
|
||
}
|
||
// With a component active, bodies outside it are drawn faded.
|
||
let faded = if let Some(names) = &active_bodies {
|
||
!names.contains(&b.name)
|
||
} else {
|
||
s.active_component != 0 && !s.doc.component_within(b.component, s.active_component)
|
||
};
|
||
let base = body_color(&s.doc, b).unwrap_or(tk.body);
|
||
let col = if faded { c4(base.gamma_multiply(0.35)) } else { c4(base) };
|
||
let m = b.mesh();
|
||
let (shaded, edges) = match app.ui.visual_style {
|
||
1 => (true, false),
|
||
2 => (false, true),
|
||
_ => (true, true),
|
||
};
|
||
let edge_col = if shaded { c4(tk.body_edge) } else { c4(tk.text) };
|
||
// Shaded with hidden edges: edges behind the model show faintly.
|
||
let hidden_edges = app.ui.visual_style == 3;
|
||
let hidden_col = c4(tk.text.gamma_multiply(0.35));
|
||
// Appearances: the body's opacity, and faces with looks of their own.
|
||
let opacity = s.doc.model_body_opacity(b);
|
||
let face_looks: std::collections::HashMap<usize, solidcraft_engine::doc::appearance::Look> =
|
||
if shaded { s.doc.face_colors(b).into_iter().collect() } else { Default::default() };
|
||
// Opaque bodies are drawn from their display plans on the GPU where the device evaluates
|
||
// them; faded and see-through ones (sorted and blended per triangle) are built here.
|
||
let opaque = !faded && opacity >= 0.999 && face_looks.values().all(|l| l.opacity >= 0.999);
|
||
let planned = (shaded && opaque && app.viewport.gpu.is_some_and(|g| g.tess))
|
||
.then(|| b.display_plan())
|
||
.flatten()
|
||
.filter(|plan| plan.vertex_count() <= solidcraft_gpu::MAX_BATCH);
|
||
let on_gpu = planned.is_some();
|
||
if let Some(plan) = planned {
|
||
let face_colors = (0..b.body.face_count())
|
||
.map(|f| match face_looks.get(&f) {
|
||
Some(l) => c4(Color32::from_rgb(l.color[0], l.color[1], l.color[2])),
|
||
None => col,
|
||
})
|
||
.collect();
|
||
sc.planned.push(crate::gpu::PlannedBody { plan, face_colors });
|
||
}
|
||
for (ti, t) in m.triangles.iter().enumerate().filter(|_| shaded && !on_gpu) {
|
||
let (mut col, a) = match m.tri_face.get(ti).and_then(|f| face_looks.get(&(*f as usize))) {
|
||
Some(l) => {
|
||
let c = Color32::from_rgb(l.color[0], l.color[1], l.color[2]);
|
||
(if faded { c4(c.gamma_multiply(0.35)) } else { c4(c) }, l.opacity)
|
||
}
|
||
None => (col, opacity),
|
||
};
|
||
let glass = !faded && a < 0.999;
|
||
if glass {
|
||
col[3] = (a.clamp(0.05, 1.0) * 255.0).round() as u8;
|
||
}
|
||
for k in t {
|
||
let i = *k as usize;
|
||
if let (Some(p), Some(n)) = (m.positions.get(i), m.normals.get(i)) {
|
||
if faded {
|
||
sc.trans_tri(p.to_f32(), n.to_f32(), col);
|
||
} else if glass {
|
||
sc.glass_tri(p.to_f32(), n.to_f32(), col);
|
||
} else {
|
||
sc.tri(p.to_f32(), n.to_f32(), col);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for (ei, e) in m.edges.iter().enumerate().filter(|_| edges) {
|
||
if !m.edge_pickable(ei) || (shaded && !m.shaded_edge_visible(ei)) {
|
||
continue;
|
||
}
|
||
for w in e.windows(2) {
|
||
sc.line(w[0].to_f32(), w[1].to_f32(), edge_col, 1.3, false);
|
||
if hidden_edges {
|
||
sc.hidden_line(w[0].to_f32(), w[1].to_f32(), hidden_col, 1.0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if app.ui.ground_shadow && app.ui.visual_style != 2 && app.cam.pitch > 0.0 {
|
||
ground_shadow(&mut sc, app, &st);
|
||
}
|
||
// Section: the outline of each body where the plane cuts it.
|
||
if let Some((o, n)) = section_plane(app) {
|
||
let col = c4(tk.text.gamma_multiply(0.9));
|
||
for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name)) {
|
||
for (a, c) in solidcraft_engine::view::section_segments(&b.mesh(), o, n) {
|
||
sc.line(a.to_f32(), c.to_f32(), col, 1.8, false);
|
||
}
|
||
}
|
||
}
|
||
|
||
sc
|
||
}
|
||
|
||
/// A soft dark ellipse on the ground under the shown bodies (darkest in the middle).
|
||
fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene {
|
||
let tk = Tokens::get();
|
||
let mut sc = GpuScene::default();
|
||
let s = &app.session;
|
||
let st = s.world_state();
|
||
let mut fill_budget = 131_072;
|
||
if app.ui.show_grid && !active_view(app).hide_grid {
|
||
let (minor, major) = grid_step(app.cam.half_height());
|
||
let ext = (app.cam.half_height() * 3.0 / major).ceil() * major;
|
||
// The ground plane: XY with Z up, XZ with Y up (laid out in the camera's Z-up frame).
|
||
let tf = app.cam.to_frame(app.cam.target);
|
||
let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0);
|
||
let w3 = |p: Vec3| app.cam.from_frame(p).to_f32();
|
||
let n = ((ext / minor) as i64).clamp(1, 300);
|
||
for i in -n..=n {
|
||
let t = i as f64 * minor;
|
||
let maj = (t / major).round() * major == t;
|
||
let col = if maj { c4(tk.grid_major) } else { c4(tk.grid) };
|
||
let w = if maj { 1.0 } else { 0.7 };
|
||
sc.under_line(w3(c + Vec3::new(t, -ext, 0.0)), w3(c + Vec3::new(t, ext, 0.0)), col, w);
|
||
sc.under_line(w3(c + Vec3::new(-ext, t, 0.0)), w3(c + Vec3::new(ext, t, 0.0)), col, w);
|
||
}
|
||
}
|
||
for sid in visible_sketches(app) {
|
||
let Some(ss) = st.sketch(sid) else { continue };
|
||
let active = s.active_sketch == Some(sid);
|
||
// Closed profiles are shaded (lifted off a face they may lie on).
|
||
let lift = ss.plane.normal() * 0.01;
|
||
let profiles = if app.ui.hidden_profiles.contains(&sid) || ss.sketch.view.hide_profile { &[][..] } else { &ss.profiles[..] };
|
||
for (index, _) in profiles.iter().enumerate() {
|
||
let display = crate::sketch_display::profile_region(app, sid, index, &ss.plane, proj);
|
||
for tri in display.as_ref().as_ref().map_or_else(Vec::new, |region| crate::sketch_display::fill_triangles(region, &mut fill_budget)) {
|
||
for q in tri {
|
||
sc.trans_tri((ss.plane.to_world(q) + lift).to_f32(), [0.0; 3], c4(tk.profile_fill));
|
||
}
|
||
}
|
||
}
|
||
// Construction curves are dashed, centerlines dash-dot (lengths follow the grid).
|
||
let unit = grid_step(app.cam.half_height()).0;
|
||
for (pts, col, cons) in sketch_lines_with_sampler(&ss.sketch, &ss.plane, active, &ss.report.curve_determined, |_, index, plane| {
|
||
crate::sketch_display::curve_sample(app, sid, index, plane, proj).parts.clone()
|
||
}) {
|
||
let segs = sketch_segments(
|
||
&pts,
|
||
if !cons {
|
||
0
|
||
} else if col == colors::SKETCH_CENTERLINE {
|
||
2
|
||
} else {
|
||
1
|
||
},
|
||
unit,
|
||
);
|
||
for (a, b) in segs {
|
||
sc.line(a.to_f32(), b.to_f32(), sketch_color(&tk, col), if cons { 1.2 } else { 2.0 }, active);
|
||
}
|
||
}
|
||
}
|
||
let guide_unit = grid_step(app.cam.half_height()).0;
|
||
for points in crate::sketch_attachment_ui::display_guide_lines(app, proj) {
|
||
for (a, b) in dashes(&points, &[guide_unit * 0.25, guide_unit * 0.15]) {
|
||
sc.line(a.to_f32(), b.to_f32(), c4(tk.warning), 1.5, true);
|
||
}
|
||
}
|
||
sc
|
||
}
|
||
|
||
fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solidcraft_engine::doc::ModelState) {
|
||
// The bodies' box in the camera's Z-up frame (the ground is its XY plane).
|
||
let mut b = solidcraft_engine::geom::Aabb3::EMPTY;
|
||
for x in st.bodies.iter().filter(|x| app.session.body_visible(&x.name)) {
|
||
let bb = x.mesh().bounds();
|
||
if bb.is_empty() {
|
||
continue;
|
||
}
|
||
for i in 0..8 {
|
||
let p = 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 },
|
||
);
|
||
b.add(app.cam.to_frame(p));
|
||
}
|
||
}
|
||
if b.is_empty() {
|
||
return;
|
||
}
|
||
let size = b.size();
|
||
let c = b.center();
|
||
let z = b.min.z - 1e-3 * (1.0 + b.diagonal());
|
||
let (rx, ry) = (size.x * 0.62 + size.z * 0.15, size.y * 0.62 + size.z * 0.15);
|
||
if !(rx > 0.0 && ry > 0.0) {
|
||
return;
|
||
}
|
||
let dark = if crate::theme::is_dark() { 150 } else { 80 };
|
||
// Rings from the middle out, fading to nothing.
|
||
let rings = [(0.0, dark), (0.55, dark * 3 / 4), (0.85, dark / 3), (1.0, 0)];
|
||
let n = 48;
|
||
let at = |r: f64, k: usize| {
|
||
let a = k as f64 / n as f64 * std::f64::consts::TAU;
|
||
app.cam.from_frame(Vec3::new(c.x + rx * r * a.cos(), c.y + ry * r * a.sin(), z)).to_f32()
|
||
};
|
||
for w in rings.windows(2) {
|
||
let ((r0, a0), (r1, a1)) = (w[0], w[1]);
|
||
let (c0, c1) = ([0, 0, 0, a0 as u8], [0, 0, 0, a1 as u8]);
|
||
for k in 0..n {
|
||
let (p00, p01, p10, p11) = (at(r0, k), at(r0, k + 1), at(r1, k), at(r1, k + 1));
|
||
sc.trans_tri(p00, [0.0; 3], c0);
|
||
sc.trans_tri(p10, [0.0; 3], c1);
|
||
sc.trans_tri(p11, [0.0; 3], c1);
|
||
if r0 > 0.0 {
|
||
sc.trans_tri(p00, [0.0; 3], c0);
|
||
sc.trans_tri(p11, [0.0; 3], c1);
|
||
sc.trans_tri(p01, [0.0; 3], c0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Sketch line types share the same world-space dash phase in the scene and overlays.
|
||
pub(crate) fn sketch_segments(pts: &[Vec3], mode: u8, unit: f64) -> Vec<(Vec3, Vec3)> {
|
||
match mode {
|
||
1 => dashes(pts, &[0.3 * unit, 0.2 * unit]),
|
||
2 => dashes(pts, &[0.6 * unit, 0.15 * unit, 0.05 * unit, 0.15 * unit]),
|
||
_ => pts.windows(2).map(|w| (w[0], w[1])).collect(),
|
||
}
|
||
}
|
||
|
||
/// The drawn pieces of a polyline in a dash pattern (on, off, on, off… lengths).
|
||
pub(crate) fn dashes(pts: &[Vec3], pattern: &[f64]) -> Vec<(Vec3, Vec3)> {
|
||
let mut out = Vec::new();
|
||
if pattern.is_empty() || pattern.iter().any(|l| !(l.is_finite() && *l > 0.0)) {
|
||
return pts.windows(2).map(|w| (w[0], w[1])).collect();
|
||
}
|
||
let (mut k, mut left) = (0usize, pattern[0]);
|
||
for w in pts.windows(2) {
|
||
let (mut a, b) = (w[0], w[1]);
|
||
let mut len = (b - a).len();
|
||
// Cap the pieces per segment so a huge zoom-out can't explode the scene.
|
||
let mut guard = 0;
|
||
while len > 1e-12 && guard < 10_000 {
|
||
guard += 1;
|
||
let step = left.min(len);
|
||
let q = a + (b - a) * (step / len);
|
||
if k % 2 == 0 {
|
||
out.push((a, q));
|
||
}
|
||
left -= step;
|
||
len -= step;
|
||
a = q;
|
||
if left <= 1e-12 {
|
||
k = (k + 1) % pattern.len();
|
||
left = pattern.get(k).copied().unwrap_or(1.0);
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// 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();
|
||
}
|
||
// While the Origin is hidden, Create Sketch may bring the planes back (empty design): then
|
||
// every plane; otherwise only those not hidden one by one.
|
||
let creating = !app.ui.show_origin && 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.session.visibility.hidden_origin.iter().any(|h| h == n))
|
||
.collect()
|
||
}
|
||
|
||
/// A point on origin plane `name`'s square that a click at its screen point picks: the middle,
|
||
/// or else the middle of a quarter the other planes don't cover from this view (in an iso view
|
||
/// the XZ square hides the middle of the XY one). The middle when no candidate is picked.
|
||
pub fn origin_plane_target(app: &SolveApp, proj: &Proj, name: &str) -> Option<Vec3> {
|
||
let (_, _, q) = origin_planes(app).into_iter().find(|(n, _, _)| *n == name)?;
|
||
let mid = (q[0] + q[1] + q[2] + q[3]) / 4.0;
|
||
let picked = |w: &Vec3| proj.to_screen(*w).is_some_and(|p| matches!(pick(app, proj, p).first(), Some(Hit::Plane { name: n, .. }) if n == name));
|
||
Some(std::iter::once(mid).chain(q.iter().map(|c| (mid + *c) / 2.0)).find(picked).unwrap_or(mid))
|
||
}
|
||
|
||
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.session.visibility.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 = view_bounds(app);
|
||
solidcraft_engine::view::construction_axes(&app.session)
|
||
.into_iter()
|
||
.filter(|(_, n, _, _)| !app.session.visibility.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)> {
|
||
solidcraft_engine::view::construction_points(&app.session)
|
||
.into_iter()
|
||
.filter(|(_, n, _)| !app.session.visibility.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;
|
||
solidcraft_engine::view::construction_planes(&app.session)
|
||
.into_iter()
|
||
.filter(|(_, n, _)| !app.session.visibility.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.
|
||
/// Bounding boxes of the shown model's bodies (cached until the model changes).
|
||
fn body_bounds(
|
||
app: &SolveApp,
|
||
st: &std::sync::Arc<solidcraft_engine::doc::ModelState>,
|
||
) -> std::sync::Arc<Vec<(String, solidcraft_engine::geom::Aabb3)>> {
|
||
if let Ok(mut g) = app.viewport.pick_bounds.lock() {
|
||
if let Some((k, b)) = g.as_ref()
|
||
&& std::sync::Arc::ptr_eq(k, st)
|
||
{
|
||
return b.clone();
|
||
}
|
||
let b = std::sync::Arc::new(st.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect::<Vec<_>>());
|
||
*g = Some((st.clone(), b.clone()));
|
||
return b;
|
||
}
|
||
std::sync::Arc::new(Vec::new())
|
||
}
|
||
|
||
type BodyBounds = (std::sync::Arc<solidcraft_engine::doc::ModelState>, std::sync::Arc<Vec<(String, solidcraft_engine::geom::Aabb3)>>);
|
||
|
||
/// The model's extent: bodies (from the cached boxes) and sketches.
|
||
pub fn model_bounds(app: &SolveApp) -> solidcraft_engine::geom::Aabb3 {
|
||
let st = app.session.world_state();
|
||
let mut b = body_bounds(app, &st).iter().fold(solidcraft_engine::geom::Aabb3::EMPTY, |a, (_, x)| a.union(x));
|
||
for ss in &st.sketches {
|
||
if let Some((lo, hi)) = ss.sketch.bounds() {
|
||
for p in [lo, hi] {
|
||
b.add(ss.plane.to_world(p));
|
||
}
|
||
}
|
||
}
|
||
for point in crate::sketch_attachment_ui::guide_lines(app).into_iter().flatten() {
|
||
b.add(point);
|
||
}
|
||
b
|
||
}
|
||
|
||
/// Where a ray enters a box (None: it misses).
|
||
fn ray_box(o: Vec3, d: Vec3, b: &solidcraft_engine::geom::Aabb3, pad: f64) -> Option<f64> {
|
||
let (mut t0, mut t1) = (f64::NEG_INFINITY, f64::INFINITY);
|
||
for (oo, dd, lo, hi) in [(o.x, d.x, b.min.x, b.max.x), (o.y, d.y, b.min.y, b.max.y), (o.z, d.z, b.min.z, b.max.z)] {
|
||
let (lo, hi) = (lo - pad, hi + pad);
|
||
if dd.abs() < 1e-15 {
|
||
if oo < lo || oo > hi {
|
||
return None;
|
||
}
|
||
} else {
|
||
let (a, c) = ((lo - oo) / dd, (hi - oo) / dd);
|
||
t0 = t0.max(a.min(c));
|
||
t1 = t1.min(a.max(c));
|
||
}
|
||
}
|
||
(t0 <= t1).then_some(t0)
|
||
}
|
||
|
||
/// A box's outline on screen, grown by `px` (None: behind the eye).
|
||
fn screen_box(proj: &Proj, b: &solidcraft_engine::geom::Aabb3, px: f32) -> Option<Rect> {
|
||
let mut r = Rect::NOTHING;
|
||
for i in 0..8 {
|
||
let p = Vec3::new(
|
||
if i & 1 == 0 { b.min.x } else { b.max.x },
|
||
if i & 2 == 0 { b.min.y } else { b.max.y },
|
||
if i & 4 == 0 { b.min.z } else { b.max.z },
|
||
);
|
||
r.extend_with(proj.to_screen(p)?);
|
||
}
|
||
Some(r.expand(px))
|
||
}
|
||
|
||
/// What is under `pos`, reusing the last answer while the cursor, the camera and the model are
|
||
/// unchanged.
|
||
fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
pos.x.to_bits().hash(&mut h);
|
||
pos.y.to_bits().hash(&mut h);
|
||
for v in [app.cam.yaw, app.cam.pitch, app.cam.roll, app.cam.distance, app.cam.target.x, app.cam.target.y, app.cam.target.z] {
|
||
v.to_bits().hash(&mut h);
|
||
}
|
||
app.session.revision.hash(&mut h);
|
||
(std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h);
|
||
app.session.active_sketch.hash(&mut h);
|
||
crate::sketch3d::drawing(app).hash(&mut h);
|
||
app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).hash(&mut h);
|
||
app.session.visibility.hidden_bodies.hash(&mut h);
|
||
app.session.visibility.hidden_occurrences.hash(&mut h);
|
||
app.ui.perspective.hash(&mut h);
|
||
(proj.rect.width().to_bits(), proj.rect.height().to_bits()).hash(&mut h);
|
||
crate::sketch_display::projection_key(proj, app.viewport.pixels_per_point()).hash(&mut h);
|
||
let key = h.finish();
|
||
if let Some((k, hits)) = &app.viewport.last_pick
|
||
&& *k == key
|
||
{
|
||
return hits.clone();
|
||
}
|
||
// Hover feedback mirrors what a 3D bridge click will snap to; clicks still go through `pick`.
|
||
let hits = pick_with_sketch_points(app, proj, pos, crate::sketch3d::drawing(app));
|
||
app.viewport.last_pick = Some((key, hits.clone()));
|
||
hits
|
||
}
|
||
|
||
/// What is under `pos`. Sketch points come from the active sketch only (from every shown sketch
|
||
/// while a dialog input takes points, while idle, or while projecting), so sketch tools never get
|
||
/// another sketch's local point and id. The 3D bridge snap asks for cross-sketch points explicitly with [`pick_3d`].
|
||
pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||
pick_with_sketch_points(app, proj, pos, false)
|
||
}
|
||
|
||
/// Like [`pick`], but also returns points of every visible sketch: only for the 3D drawing snap,
|
||
/// which converts them to world space through their own sketch's plane.
|
||
pub fn pick_3d(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||
pick_with_sketch_points(app, proj, pos, true)
|
||
}
|
||
|
||
/// Everything under `pos` (Select Other): like [`pick`], but every sketch point, sketch curve,
|
||
/// vertex, edge, axis and profile near the cursor, and the nearest face of every body along the
|
||
/// ray, instead of the nearest of each kind. Each kind is nearest first.
|
||
pub fn pick_all(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||
pick_hits(app, proj, pos, false, true)
|
||
}
|
||
|
||
fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool) -> Vec<Hit> {
|
||
pick_hits(app, proj, pos, other_sketch_points, false)
|
||
}
|
||
|
||
/// The same thing picked twice (one row per target in [`pick_all`]).
|
||
pub fn same_target(a: &Hit, b: &Hit) -> bool {
|
||
match (a, b) {
|
||
(Hit::SketchPoint { sketch: s, id: i, .. }, Hit::SketchPoint { sketch: t, id: j, .. })
|
||
| (Hit::SketchCurve { sketch: s, id: i, .. }, Hit::SketchCurve { sketch: t, id: j, .. }) => s == t && i == j,
|
||
(Hit::Vertex { body: a, point: p }, Hit::Vertex { body: b, point: q }) => a == b && p.dist(*q) < 1e-9,
|
||
(Hit::Edge { body: a, index: i, .. }, Hit::Edge { body: b, index: j, .. })
|
||
| (Hit::Face { body: a, index: i, .. }, Hit::Face { body: b, index: j, .. }) => a == b && i == j,
|
||
(Hit::Profile { sketch: s, index: i }, Hit::Profile { sketch: t, index: j }) => s == t && i == j,
|
||
(Hit::Plane { name: a, .. }, Hit::Plane { name: b, .. }) | (Hit::Axis { name: a }, Hit::Axis { name: b }) => a == b,
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// The candidates of one kind: the nearest only, or with `all` every target (nearest first).
|
||
struct Near<T> {
|
||
all: bool,
|
||
v: Vec<(T, Hit)>,
|
||
}
|
||
|
||
impl<T: PartialOrd + Copy> Near<T> {
|
||
fn new(all: bool) -> Self {
|
||
Near { all, v: Vec::new() }
|
||
}
|
||
/// Whether a candidate at `d` would be kept.
|
||
fn wants(&self, d: T) -> bool {
|
||
self.all || self.v.first().is_none_or(|(b, _)| d < *b)
|
||
}
|
||
fn offer(&mut self, d: T, h: Hit) {
|
||
if !self.all {
|
||
if self.wants(d) {
|
||
self.v = vec![(d, h)];
|
||
}
|
||
return;
|
||
}
|
||
match self.v.iter_mut().find(|(_, x)| same_target(x, &h)) {
|
||
Some(e) if d < e.0 => *e = (d, h),
|
||
Some(_) => {}
|
||
None => self.v.push((d, h)),
|
||
}
|
||
}
|
||
fn sorted(mut self) -> Vec<(T, Hit)> {
|
||
self.v.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||
self.v
|
||
}
|
||
fn hits(self) -> impl Iterator<Item = Hit> {
|
||
self.sorted().into_iter().map(|x| x.1)
|
||
}
|
||
}
|
||
|
||
fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, all: bool) -> Vec<Hit> {
|
||
let s = &app.session;
|
||
let st = s.world_state();
|
||
let bounds = body_bounds(app, &st);
|
||
let bound = |name: &str| bounds.iter().find(|(n, _)| n == name).map(|(_, b)| *b);
|
||
let mut hits = Vec::new();
|
||
let (o, d) = proj.ray(pos);
|
||
// Sketch points of the active sketch (of every shown sketch while a command takes points,
|
||
// while idle or projecting, and while 3D drawing snaps to points on other visible sketches).
|
||
let wants_points = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::POINTS != 0);
|
||
let external_points =
|
||
wants_points || other_sketch_points || (app.dialog.is_none() && app.tool.as_ref().is_none_or(|tool| tool.cmd == "sketch.project"));
|
||
let point_sketches: Vec<u64> = if external_points { visible_sketches(app) } else { s.active_sketch.into_iter().collect() };
|
||
// A face place (the Single hole's) snaps only to points their sketches show, so a nearer
|
||
// point that isn't shown (a letter's outline) neither takes the pick nor hides a shown one.
|
||
let snapping = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| crate::dialogs::snaps_to_points(i.accept));
|
||
let mut best = Near::new(all);
|
||
for ss in point_sketches.iter().filter_map(|sid| st.sketch(*sid)) {
|
||
let sid = ss.feature;
|
||
let shown = snapping.then(|| crate::dialogs::SnapPoints::of(&ss.sketch));
|
||
let visible = solidcraft_engine::view::SketchPointVisibility::new(&ss.sketch);
|
||
for p in &ss.sketch.points {
|
||
if !visible.takes(p) || shown.as_ref().is_some_and(|f| !f.takes(p)) {
|
||
continue;
|
||
}
|
||
if let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) {
|
||
let dd = sp.distance(pos) - if s.active_sketch == Some(sid) { 2.0 } else { 0.0 };
|
||
if dd < 8.0 && best.wants(dd) {
|
||
best.offer(dd, Hit::SketchPoint { sketch: sid, id: p.id.clone(), at: p.pos });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
hits.extend(best.hits());
|
||
// Sketch curves (the active sketch wins ties).
|
||
let mut bestc = Near::new(all);
|
||
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() {
|
||
if !solidcraft_engine::view::sketch_curve_visible(&ss.sketch, i) {
|
||
continue;
|
||
}
|
||
let straight = matches!(c.kind, CurveKind::Line { .. });
|
||
for part in &crate::sketch_display::curve_sample(app, sid, i, &ss.plane, proj).parts {
|
||
let pts: Vec<Pos2> = part.iter().filter_map(|q| proj.to_screen(*q)).collect();
|
||
for w in pts.windows(2) {
|
||
let dd = seg_dist(pos, w[0], w[1]) - bonus;
|
||
if dd < 6.0 && bestc.wants(dd) {
|
||
bestc.offer(dd, Hit::SketchCurve { sketch: sid, id: c.id.clone(), straight });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 3D sketch curves (paths for sweeps and pipes).
|
||
for w in &ss.sketch.wires {
|
||
let pts: Vec<Pos2> = w.pts.iter().filter_map(|q| proj.to_screen(*q)).collect();
|
||
for seg in pts.windows(2) {
|
||
let dd = seg_dist(pos, seg[0], seg[1]) - bonus;
|
||
if dd < 6.0 && bestc.wants(dd) {
|
||
bestc.offer(dd, Hit::SketchCurve { sketch: sid, id: w.id.clone(), straight: false });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
hits.extend(bestc.hits());
|
||
// Nearest face along the ray (hides what is behind it); with `all`, every body's nearest.
|
||
let mut bestf: Option<(f64, Hit)> = None;
|
||
let mut faces = Near::new(all);
|
||
for b in &st.bodies {
|
||
if !app.session.body_visible(&b.name) {
|
||
continue;
|
||
}
|
||
// Bodies whose box the ray misses (or enters behind the best hit so far) are skipped.
|
||
if let Some(bb) = bound(&b.name) {
|
||
let pad = bb.diagonal() * 1e-6 + 1e-9;
|
||
match ray_box(o, d, &bb, pad) {
|
||
None => continue,
|
||
Some(t0) if !all && bestf.as_ref().is_some_and(|(bt, _)| t0 > *bt) => continue,
|
||
_ => {}
|
||
}
|
||
}
|
||
let m = b.mesh();
|
||
if let Some((t, ti)) = m.raycast(o, d) {
|
||
let fi = m.tri_face.get(ti).copied().unwrap_or(0) as usize;
|
||
let hit = Hit::Face { body: b.name.clone(), index: fi, point: o + d * t };
|
||
if bestf.as_ref().is_none_or(|(bt, _)| t < *bt) {
|
||
bestf = Some((t, hit.clone()));
|
||
}
|
||
if all {
|
||
faces.offer(t, hit);
|
||
}
|
||
}
|
||
}
|
||
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 = Near::new(all);
|
||
let mut beste = Near::new(all);
|
||
for b in &st.bodies {
|
||
if !app.session.body_visible(&b.name) {
|
||
continue;
|
||
}
|
||
// Only bodies whose outline on screen is near the cursor can have an edge under it.
|
||
if let Some(bb) = bound(&b.name)
|
||
&& screen_box(proj, &bb, 8.0).is_some_and(|r| !r.contains(pos))
|
||
{
|
||
continue;
|
||
}
|
||
let m = b.mesh();
|
||
for (ei, e) in m.edges.iter().enumerate() {
|
||
if !m.edge_pickable(ei) {
|
||
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.wants(dd) {
|
||
bestv.offer(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.wants(dd) {
|
||
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.offer(dd, Hit::Edge { body: b.name.clone(), index: ei, mid });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
hits.extend(bestv.hits());
|
||
hits.extend(beste.hits());
|
||
// Origin axes.
|
||
let size = origin_size(app.cam.half_height());
|
||
let mut besta = Near::new(all);
|
||
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.wants(dd) {
|
||
besta.offer(dd, Hit::Axis { name: name.into() });
|
||
}
|
||
}
|
||
}
|
||
// 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.wants(dd) {
|
||
besta.offer(dd, Hit::Axis { name });
|
||
}
|
||
}
|
||
}
|
||
hits.extend(besta.hits());
|
||
// 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;
|
||
let mut profiles_all: Vec<(f64, f64, Hit)> = Vec::new();
|
||
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);
|
||
// Show Profile off: the sketch being edited offers no profiles.
|
||
let profiles = if ss.sketch.view.hide_profile && s.active_sketch == Some(sid) { &[][..] } else { &ss.profiles[..] };
|
||
for (pi, p) in profiles.iter().enumerate() {
|
||
if !p.region.contains(lp) {
|
||
continue;
|
||
}
|
||
if all {
|
||
profiles_all.push((t, p.area, Hit::Profile { sketch: sid, index: pi }));
|
||
} else if bestp.as_ref().is_none_or(|(_, a, _)| p.area < *a) {
|
||
bestp = Some((t, p.area, Hit::Profile { sketch: sid, index: pi }));
|
||
}
|
||
}
|
||
}
|
||
// Smallest profile first.
|
||
profiles_all.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||
for (t, _, h) in bestp.into_iter().chain(profiles_all) {
|
||
surf.push((t - slack, h));
|
||
}
|
||
if all {
|
||
surf.extend(faces.sorted());
|
||
} else if let Some(f) = bestf {
|
||
surf.push(f);
|
||
}
|
||
// Planes give way to a face or profile at the same depth (a floor on the XZ plane).
|
||
for (name, _, q) in origin_planes(app) {
|
||
if let Some(t) = ray_quad(o, d, &q) {
|
||
surf.push((t + slack, 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 + slack, 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
|
||
}
|
||
|
||
/// The section cut shown now: the open Section Analysis dialog's plane, else the session's.
|
||
/// The active sketch's display options (defaults when not sketching).
|
||
pub fn active_view(app: &SolveApp) -> solidcraft_engine::sketch::SketchView {
|
||
let st = app.session.world_state();
|
||
app.session.active_sketch.and_then(|s| st.sketch(s)).map(|ss| ss.sketch.view.clone()).unwrap_or_default()
|
||
}
|
||
|
||
pub fn section_plane(app: &SolveApp) -> Option<(Vec3, Vec3)> {
|
||
if let Some(d) = app.dialog.as_ref().filter(|d| matches!(d.kind, crate::dialogs::Kind::Section { .. })) {
|
||
let cmds = crate::dialogs::apply_commands(app, d).ok()?;
|
||
let (_, p) = cmds.into_iter().next()?;
|
||
let mut scratch = app.session.scratch();
|
||
scratch.execute("inspect.section", &p).ok()?;
|
||
return scratch.section;
|
||
}
|
||
// Slice (Sketch Palette): cut the model at the plane of the sketch being edited.
|
||
if app.session.section.is_none() {
|
||
let st = app.session.world_state();
|
||
if let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)).filter(|ss| ss.sketch.view.slice) {
|
||
return Some((ss.plane.origin, ss.plane.normal()));
|
||
}
|
||
}
|
||
app.session.section
|
||
}
|
||
|
||
/// Visible geometry belonging to a timeline feature. Body creators retain their whole-body
|
||
/// feedback; modifiers use persistent face names, which survive later edits and placement.
|
||
/// Do not expand these faces into surface patches: neighbouring faces can belong to another
|
||
/// feature even when they share a surface.
|
||
fn feature_geometry(app: &SolveApp, id: u64) -> Vec<Sel> {
|
||
if !app.session.model.result(id).is_some_and(|r| !r.skipped && r.error.is_none()) {
|
||
return Vec::new();
|
||
}
|
||
let st = app.session.world_state();
|
||
let prefix = format!("F{id}:");
|
||
let mut out = Vec::new();
|
||
for b in &st.bodies {
|
||
if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) {
|
||
continue;
|
||
}
|
||
if b.feature == id {
|
||
out.push(Sel::Body { name: b.name.clone() });
|
||
continue;
|
||
}
|
||
let names = solidcraft_engine::doc::naming::face_names(b);
|
||
let m = b.mesh();
|
||
for (index, _) in names.iter().enumerate().filter(|(_, n)| n.starts_with(&prefix)) {
|
||
if let Some(point) = m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| {
|
||
if *f as usize != index {
|
||
return None;
|
||
}
|
||
m.tri(t).map(|[a, b, c]| (a + b + c) / 3.0)
|
||
}) {
|
||
out.push(Sel::Face { body: b.name.clone(), index, point });
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn displayed_selection(app: &SolveApp, item: &Sel) -> Vec<Sel> {
|
||
match item {
|
||
Sel::Feature { id } => feature_geometry(app, *id),
|
||
Sel::Occurrence { path } => app
|
||
.session
|
||
.occurrence_bodies(path, true)
|
||
.unwrap_or_default()
|
||
.into_iter()
|
||
.filter(|name| app.session.body_visible(name))
|
||
.map(|name| Sel::Body { name })
|
||
.collect(),
|
||
_ => grouped_selection(&app.session, item),
|
||
}
|
||
}
|
||
|
||
/// The box around what is selected (bodies, faces, edges, vertices, sketch curves and the
|
||
/// bodies of selected features), where it is shown.
|
||
pub fn selection_bounds(app: &SolveApp) -> Option<solidcraft_engine::geom::Aabb3> {
|
||
let st = app.session.world_state();
|
||
let mut b = solidcraft_engine::geom::Aabb3::EMPTY;
|
||
for s in app.session.selection.iter().flat_map(|item| displayed_selection(app, item)) {
|
||
match &s {
|
||
Sel::Body { name } => {
|
||
if let Some(x) = st.body(name) {
|
||
b = b.union(&x.mesh().bounds());
|
||
}
|
||
}
|
||
Sel::Face { body, index, .. } => {
|
||
if let Some(x) = st.body(body) {
|
||
let m = x.mesh();
|
||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||
if *f as usize == *index {
|
||
for p in m.tri(t).into_iter().flatten() {
|
||
b.add(p);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Sel::Edge { body, index, .. } => {
|
||
if let Some(e) = st.body(body).and_then(|x| x.mesh().edges.get(*index).cloned()) {
|
||
for p in e {
|
||
b.add(p);
|
||
}
|
||
}
|
||
}
|
||
Sel::Vertex { point, .. } => b.add(*point),
|
||
Sel::ExternalSketchPoint { sketch, id } => {
|
||
if let Some(ss) = st.sketch(*sketch)
|
||
&& let Some(p) = ss.sketch.resolve_point(id).and_then(|i| ss.sketch.point(i))
|
||
{
|
||
b.add(ss.plane.to_world(p));
|
||
}
|
||
}
|
||
Sel::ExternalSketchCurve { sketch, id } => {
|
||
if let Some(ss) = st.sketch(*sketch)
|
||
&& let Some(i) = ss.sketch.curve_index(id)
|
||
{
|
||
for p in ss.sketch.polyline(i) {
|
||
b.add(ss.plane.to_world(p));
|
||
}
|
||
}
|
||
}
|
||
Sel::SketchCurve { id } => {
|
||
for ss in &st.sketches {
|
||
if let Some(i) = ss.sketch.curve_index(id) {
|
||
for q in ss.sketch.polyline(i) {
|
||
b.add(ss.plane.to_world(q));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
(!b.is_empty()).then_some(b)
|
||
}
|
||
|
||
/// The section as the shader's clip plane (normal, d), zero when there is none.
|
||
/// The surface analysis for the shader (see `gpu::ViewportCallback::analysis`).
|
||
fn analysis_uniform(app: &SolveApp) -> [f32; 8] {
|
||
use solidcraft_engine::SurfaceAnalysis as A;
|
||
match app.session.analysis {
|
||
Some(A::Zebra { stripes }) => [1.0, stripes as f32, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
|
||
Some(A::Draft { pull, angle }) => [2.0, angle as f32, 0.0, 0.0, pull.x as f32, pull.y as f32, pull.z as f32, 0.0],
|
||
Some(A::Curvature { radius }) => [3.0, radius as f32, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
|
||
Some(A::Environment) => [4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
|
||
Some(A::Access { dir }) => {
|
||
let (scale, size, eps) = crate::access::map(app).map(|m| (m.scale, m.size as f32, 1.5 / m.scale.max(1e-9))).unwrap_or((0.0, 0.0, 0.0));
|
||
[5.0, eps, scale, size, dir.x as f32, dir.y as f32, dir.z as f32, 0.0]
|
||
}
|
||
None => [0.0; 8],
|
||
}
|
||
}
|
||
|
||
fn section_clip(app: &SolveApp) -> [f32; 4] {
|
||
match section_plane(app) {
|
||
Some((o, n)) => [n.x as f32, n.y as f32, n.z as f32, n.dot(o) as f32],
|
||
None => [0.0; 4],
|
||
}
|
||
}
|
||
|
||
/// Space kept free at the top right of the viewport for the axis view (docked dialogs start below).
|
||
pub(crate) fn axis_view_size(style: crate::prefs::NavigatorStyle) -> f32 {
|
||
match style {
|
||
crate::prefs::NavigatorStyle::Axes => 116.0,
|
||
crate::prefs::NavigatorStyle::Cube => navigation_cube::SIZE,
|
||
}
|
||
}
|
||
// Keep command panels anchored independently of the compact cube's display size.
|
||
pub const AXIS_VIEW_CLEARANCE: f32 = 168.0;
|
||
|
||
/// 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()) {
|
||
let snap = d.face_snap(&app.session, hits);
|
||
return hits.iter().find_map(|h| d.candidate_on(&app.session, h, snap.as_ref()).map(|s| (h.clone(), s)));
|
||
}
|
||
hits.iter().filter(|h| pick_allowed(app, h)).find_map(|h| hit_candidate(app, h))
|
||
}
|
||
|
||
/// Every hit a click could pick, in order, each with its selection (Select Other).
|
||
pub fn candidates(app: &SolveApp, hits: &[Hit]) -> Vec<(Hit, Sel)> {
|
||
if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) {
|
||
// The face under the cursor is looked up once for every sketch point a face place may
|
||
// snap to, before Select Other caps its rows.
|
||
let snap = d.face_snap(&app.session, hits);
|
||
return hits.iter().filter_map(|h| d.candidate_on(&app.session, h, snap.as_ref()).map(|s| (h.clone(), s))).collect();
|
||
}
|
||
hits.iter().filter(|h| pick_allowed(app, h)).filter_map(|h| hit_candidate(app, h)).collect()
|
||
}
|
||
|
||
/// What the selection filter lets a click pick with no dialog.
|
||
fn pick_allowed(app: &SolveApp, h: &Hit) -> bool {
|
||
let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k);
|
||
match h {
|
||
Hit::SketchCurve { .. } => !off("sketch") && !off("sketch_curves"),
|
||
Hit::SketchPoint { .. } => !off("sketch") && !off("sketch_points"),
|
||
Hit::Profile { .. } => !off("sketch"),
|
||
Hit::Vertex { .. } => !off("vertices") && !app.ui.pick_bodies,
|
||
Hit::Edge { .. } => !off("edges") && !app.ui.pick_bodies,
|
||
Hit::Face { .. } => !off("faces") || app.ui.pick_bodies,
|
||
Hit::Plane { .. } | Hit::Axis { .. } => true,
|
||
}
|
||
}
|
||
|
||
/// The selection a click on `h` makes with no dialog (sketch entities only belong to the active sketch).
|
||
fn hit_candidate(app: &SolveApp, h: &Hit) -> Option<(Hit, Sel)> {
|
||
match h {
|
||
Hit::SketchCurve { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => {
|
||
Some((h.clone(), Sel::ExternalSketchCurve { sketch: *sketch, id: id.clone() }))
|
||
}
|
||
Hit::SketchPoint { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => {
|
||
Some((h.clone(), Sel::ExternalSketchPoint { sketch: *sketch, id: id.clone() }))
|
||
}
|
||
Hit::Face { body, .. } if app.ui.pick_bodies => Some((h.clone(), Sel::Body { name: body.clone() })),
|
||
_ => hit_sel(h).map(|s| (h.clone(), s)),
|
||
}
|
||
}
|
||
|
||
/// A pick for the open dialog's active input (a click, or a Select Other row).
|
||
pub(crate) fn dialog_pick(app: &mut SolveApp, hit: &Hit, sel: Sel) {
|
||
let Some(mut d) = app.dialog.take() else { return };
|
||
d.pick(&app.session, sel);
|
||
if let crate::dialogs::Kind::PathPlane { sketch, .. } = &mut d.kind {
|
||
*sketch = match hit {
|
||
Hit::SketchCurve { sketch, .. } => Some(*sketch),
|
||
_ => None,
|
||
};
|
||
d.extra.remove("path");
|
||
}
|
||
app.dialog = Some(d);
|
||
}
|
||
|
||
/// 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: &solidcraft_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: &solidcraft_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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A highlighted edge: a halo and a core, and where the model hides it, the core dimmed (so a
|
||
/// pick on the far side still shows).
|
||
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);
|
||
}
|
||
}
|
||
for p in e.windows(2) {
|
||
sc.hidden_line(p[0].to_f32(), p[1].to_f32(), c4(core.gamma_multiply(0.45)), (w * 0.7).max(1.0));
|
||
}
|
||
}
|
||
|
||
/// The outline of a highlighted face where the model hides it (dimmed).
|
||
fn hidden_face_outline(sc: &mut GpuScene, m: &solidcraft_engine::geom::Mesh, face: usize, col: Color32) {
|
||
for (ei, e) in m.edges.iter().enumerate() {
|
||
let on = m.edge_faces.get(ei).is_some_and(|f| f.iter().any(|x| *x as usize == face));
|
||
if on && m.edge_pickable(ei) {
|
||
for p in e.windows(2) {
|
||
sc.hidden_line(p[0].to_f32(), p[1].to_f32(), c4(col.gamma_multiply(0.45)), 1.4);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) {
|
||
let st = app.session.world_state();
|
||
let Some(ss) = st.sketch(sketch) else { return };
|
||
let Some(p) = ss.profiles.get(index) else { return };
|
||
let plane = match crate::dialogs::emboss_profile_plane(app, sketch) {
|
||
Ok(Some(plane)) => plane,
|
||
Ok(None) => ss.plane,
|
||
Err(_) => 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: solidcraft_engine::geom::Vec2| (plane.to_world(q) + lift).to_f32();
|
||
let col = c4(fill);
|
||
// In the translucent pass (after the grid and edges) so it covers them.
|
||
let proj = display_proj(app);
|
||
let display = crate::sketch_display::profile_region(app, sketch, index, &plane, &proj);
|
||
for [a, b, c] in display.as_ref().as_ref().map_or_else(Vec::new, |region| crate::sketch_display::fill_triangles(region, &mut 65_536)) {
|
||
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) {
|
||
for part in crate::sketch_display::boundary_parts(lp, &plane, &proj, app.viewport.pixels_per_point()) {
|
||
for pair in part.windows(2) {
|
||
sc.line((pair[0] + lift).to_f32(), (pair[1] + lift).to_f32(), 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.viewport.hover_feature.hash(&mut h);
|
||
app.viewport.hover_bodies.hash(&mut h);
|
||
app.ui.pick_bodies.hash(&mut h);
|
||
crate::theme::is_dark().hash(&mut h);
|
||
app.preview.replaced.hash(&mut h);
|
||
app.origin_visible().hash(&mut h);
|
||
app.session.visibility.hidden_origin.hash(&mut h);
|
||
app.session.visibility.hidden_bodies.hash(&mut h);
|
||
app.session.visibility.hidden_occurrences.hash(&mut h);
|
||
app.cam.half_height().to_bits().hash(&mut h);
|
||
app.cam.back().x.to_bits().hash(&mut h);
|
||
crate::sketch_display::projection_key(&display_proj(app), app.viewport.pixels_per_point()).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.world_state();
|
||
let mut sel = Vec::new();
|
||
for item in app.highlighted() {
|
||
for piece in displayed_selection(app, &item) {
|
||
if !sel.iter().any(|x| crate::selection::same_item(x, &piece)) {
|
||
sel.push(piece);
|
||
}
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
// 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 {
|
||
Sel::Face { body, index, .. } => {
|
||
// A live preview stands in for the body: its old faces would fight with it.
|
||
if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !app.preview.replaced.contains(body)) {
|
||
face_tris(&mut sc, &b.mesh(), *index, c4(t.sel_face), false);
|
||
hidden_face_outline(&mut sc, &b.mesh(), *index, t.sel_edge);
|
||
}
|
||
}
|
||
Sel::Body { name } => {
|
||
if let Some(b) = st.body(name)
|
||
&& app.session.body_visible(name)
|
||
{
|
||
let m = b.mesh();
|
||
for f in 0..b.body.face_count() {
|
||
face_tris(&mut sc, &m, f, c4(t.body_selected), true);
|
||
}
|
||
}
|
||
}
|
||
Sel::Edge { body, index, point } => {
|
||
if let Some(b) = st.body(body)
|
||
&& app.session.body_visible(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::ExternalSketchCurve { sketch, id } => {
|
||
if let Some(ss) = st.sketch(*sketch)
|
||
&& let Some(ci) = ss.sketch.curve_index(id)
|
||
{
|
||
for pts in &crate::sketch_display::curve_sample(app, *sketch, ci, &ss.plane, &display_proj(app)).parts {
|
||
let mode = ss.sketch.curves.get(ci).map_or(0, |c| if c.centerline { 2 } else { u8::from(c.construction) });
|
||
for (a, b) in sketch_segments(pts, mode, grid_step(app.cam.half_height()).0) {
|
||
sc.line(a.to_f32(), b.to_f32(), c4(t.sel_edge), 3.5, true);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// A 3D sketch curve, in whichever sketch has it.
|
||
Sel::SketchCurve { id } if st.sketches.iter().any(|ss| ss.sketch.wire_index(id).is_some()) => {
|
||
for ss in st.sketches.iter() {
|
||
if let Some(w) = ss.sketch.wire_index(id).and_then(|i| ss.sketch.wires.get(i)) {
|
||
for q in w.pts.windows(2) {
|
||
sc.line(q[0].to_f32(), q[1].to_f32(), c4(t.sel_edge), 3.5, true);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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 pts in &crate::sketch_display::curve_sample(app, ss.feature, ci, &ss.plane, &display_proj(app)).parts {
|
||
let mode = ss.sketch.curves.get(ci).map_or(0, |c| if c.centerline { 2 } else { u8::from(c.construction) });
|
||
for (a, b) in sketch_segments(pts, mode, grid_step(app.cam.half_height()).0) {
|
||
sc.line(a.to_f32(), b.to_f32(), c4(t.sel_edge), 3.5, true);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
// Timeline modifiers light only their resulting faces; browser rows light whole bodies.
|
||
let mut hovered = app.viewport.hover_feature.map(|id| feature_geometry(app, id)).unwrap_or_default();
|
||
hovered.extend(app.viewport.hover_bodies.iter().map(|name| Sel::Body { name: name.clone() }));
|
||
for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name) && !app.preview.replaced.contains(&b.name)) {
|
||
let whole = hovered.iter().any(|x| matches!(x, Sel::Body { name } if name == &b.name));
|
||
let faces: Vec<_> = if whole {
|
||
(0..b.body.face_count()).collect()
|
||
} else {
|
||
hovered
|
||
.iter()
|
||
.filter_map(|x| match x {
|
||
Sel::Face { body, index, .. } if body == &b.name => Some(*index),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
};
|
||
if faces.is_empty() {
|
||
continue;
|
||
}
|
||
let k = t.hover_face_lift;
|
||
let c = body_rgb(app, &b.name);
|
||
let m = b.mesh();
|
||
for &f in &faces {
|
||
if !sel.iter().any(|x| {
|
||
matches!(x, Sel::Face { body, index, .. } if body == &b.name && *index == f) || matches!(x, Sel::Body { name } if name == &b.name)
|
||
}) {
|
||
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
|
||
}
|
||
}
|
||
for (ei, e) in m.edges.iter().enumerate() {
|
||
if m.edge_pickable(ei) && (whole || m.edge_faces.get(ei).is_some_and(|fs| fs.iter().any(|f| faces.contains(&(*f as usize))))) {
|
||
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 1.5);
|
||
}
|
||
}
|
||
}
|
||
// Hover (pre-highlight).
|
||
match hover {
|
||
// Body selection: the whole body lights up.
|
||
Some(Hit::Face { body, .. }) if app.ui.pick_bodies && app.dialog.is_none() && app.tool.is_none() => {
|
||
if let Some(b) = st.body(body) {
|
||
let k = t.hover_face_lift;
|
||
let c = body_rgb(app, body);
|
||
let m = b.mesh();
|
||
for f in 0..b.body.face_count() {
|
||
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
|
||
}
|
||
}
|
||
}
|
||
Some(Hit::Face { body, index, .. })
|
||
if !app.preview.replaced.contains(body)
|
||
&& !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 = body_rgb(app, body);
|
||
let m = b.mesh();
|
||
let patch = m.surface_patch(*index);
|
||
for &f in &patch {
|
||
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
|
||
}
|
||
for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) {
|
||
if let Some(p) = m.edges.get(e)
|
||
&& m.edge_pickable(e)
|
||
{
|
||
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 m = b.mesh();
|
||
for i in m.circular_rim(*index) {
|
||
let selected = sel.iter().any(|x| matches!(x, Sel::Edge { body: b, index: j, .. } if b == body && *j == i));
|
||
if !selected && let Some(e) = if i == *index { edge_of(&m, i, *mid) } else { m.edges.get(i) } {
|
||
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
|
||
}
|
||
|
||
/// A body's display colour (appearance, material or imported colour), if it has one.
|
||
fn body_color(doc: &solidcraft_engine::doc::Document, b: &solidcraft_engine::doc::ModelBody) -> Option<Color32> {
|
||
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||
doc.body_color(b).map(|[r, g, b]| Color32::from_rgb(to8(r), to8(g), to8(b)))
|
||
}
|
||
|
||
/// A body's display colour (else the theme's) as RGB.
|
||
fn body_rgb(app: &SolveApp, name: &str) -> (u8, u8, u8) {
|
||
let st = app.session.world_state();
|
||
let c = st.body(name).and_then(|b| body_color(&app.session.doc, b)).unwrap_or(Tokens::get().body);
|
||
(c.r(), c.g(), c.b())
|
||
}
|
||
|
||
pub(crate) 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;
|
||
for w in pts.windows(2) {
|
||
let l = w[0].dist(w[1]);
|
||
if acc + l >= len * 0.5 && l > 0.0 {
|
||
return w[0].lerp(w[1], (len * 0.5 - acc) / l);
|
||
}
|
||
acc += l;
|
||
}
|
||
pts.first().copied().unwrap_or_default()
|
||
}
|
||
|
||
fn seg_dist(p: Pos2, a: Pos2, b: Pos2) -> f32 {
|
||
let ab = b - a;
|
||
let l2 = ab.length_sq();
|
||
let t = if l2 > 0.0 { ((p - a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||
p.distance(a + ab * t)
|
||
}
|
||
|
||
/// Point on the active sketch plane under the cursor (sketch coordinates), snapped to sketch
|
||
/// points within a few pixels and otherwise to a fine grid. Returns the snapped point and the
|
||
/// id of the sketch point it snapped to.
|
||
pub fn sketch_point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec2, Option<String>)> {
|
||
let sid = app.session.active_sketch?;
|
||
let st = app.session.world_state();
|
||
let ss = st.sketch(sid)?;
|
||
for h in pick(app, proj, pos) {
|
||
if let Hit::SketchPoint { sketch, id, at } = h
|
||
&& sketch == sid
|
||
{
|
||
return Some((at, Some(id)));
|
||
}
|
||
}
|
||
let (o, d) = proj.ray(pos);
|
||
let w = ss.plane.intersect_ray(o, d)?;
|
||
let lp = ss.plane.to_local(w);
|
||
if crate::drag_snap::suppressed() {
|
||
return Some((lp, None));
|
||
}
|
||
let (minor, _) = grid_step(app.cam.half_height());
|
||
let step = minor / 10.0;
|
||
if let Some(p) = crate::sketch_tools::refine_snap(app, proj, &ss.sketch, &ss.plane, lp) {
|
||
return Some(p);
|
||
}
|
||
// With Snap off (Sketch Palette) the point is where the cursor is.
|
||
if ss.sketch.view.no_snap {
|
||
return Some((lp, None));
|
||
}
|
||
Some((Vec2::new((lp.x / step).round() * step, (lp.y / step).round() * step), None))
|
||
}
|
||
|
||
pub fn delete_selection(app: &mut SolveApp) {
|
||
crate::delete::delete_selection(app);
|
||
}
|
||
|
||
/// Raw wheel/trackpad input for one frame, split by source.
|
||
///
|
||
/// Mouse wheels report lines or pages; trackpads report points. Trackpad
|
||
/// scrolls arrive bracketed (`Start` … `Move` … `End`, momentum included)
|
||
/// while mouse wheels send bare `Move` events, so phases tell them apart.
|
||
struct RawWheel {
|
||
/// Vertical mouse-wheel input in points (zooms).
|
||
zoom: f32,
|
||
/// Trackpad point input, routed like the wheel (zooms when panning is off).
|
||
point_zoom: f32,
|
||
/// Bracketed two-finger scroll in points (pans when panning is on).
|
||
pan: egui::Vec2,
|
||
/// Bare horizontal input in points (thumb wheel: always pans sideways).
|
||
mouse_pan: egui::Vec2,
|
||
}
|
||
|
||
/// Route wheel points to the vertical axis, matching egui's scroll modifiers.
|
||
fn route_wheel_vertical(points: egui::Vec2, modifiers: egui::Modifiers, options: egui::InputOptions) -> f32 {
|
||
let horizontal = modifiers.matches_any(options.horizontal_scroll_modifier);
|
||
let vertical = modifiers.matches_any(options.vertical_scroll_modifier);
|
||
if horizontal && !vertical {
|
||
0.0
|
||
} else if vertical && !horizontal {
|
||
points.x + points.y
|
||
} else {
|
||
points.y
|
||
}
|
||
}
|
||
|
||
/// Match egui's wheel units and modifier routing, without its temporal smoothing.
|
||
///
|
||
/// `gesture` tracks an ongoing bracketed trackpad scroll (`Start` … `End`):
|
||
/// moves inside one pan, bare `Move` input is a mouse (vertical zooms,
|
||
/// horizontal pans). Only point input brackets; line/page input always zooms.
|
||
fn immediate_wheel(input: &egui::InputState, options: egui::InputOptions, page_height: f32, gesture: &mut bool) -> RawWheel {
|
||
let mut out = RawWheel { zoom: 0.0, point_zoom: 0.0, pan: egui::Vec2::ZERO, mouse_pan: egui::Vec2::ZERO };
|
||
for event in &input.events {
|
||
let egui::Event::MouseWheel { unit, delta, modifiers, phase } = event else { continue };
|
||
if modifiers.matches_any(options.zoom_modifier) {
|
||
continue;
|
||
}
|
||
let point = matches!(unit, egui::MouseWheelUnit::Point);
|
||
match phase {
|
||
egui::TouchPhase::Start => {
|
||
if point {
|
||
*gesture = true;
|
||
}
|
||
continue;
|
||
}
|
||
egui::TouchPhase::End | egui::TouchPhase::Cancel => {
|
||
if point {
|
||
*gesture = false;
|
||
}
|
||
continue;
|
||
}
|
||
egui::TouchPhase::Move => {}
|
||
}
|
||
let points = match unit {
|
||
egui::MouseWheelUnit::Point => *delta,
|
||
egui::MouseWheelUnit::Line => *delta * options.line_scroll_speed,
|
||
egui::MouseWheelUnit::Page => *delta * page_height,
|
||
};
|
||
if point && *gesture {
|
||
out.pan += points;
|
||
// Legacy path only (ignored while panning is on).
|
||
out.point_zoom += route_wheel_vertical(points, *modifiers, options);
|
||
} else if point {
|
||
// Bare moves are a mouse: vertical zooms, horizontal pans sideways.
|
||
// A wheel needs the opposite sign of fingers for the same motion.
|
||
out.zoom += route_wheel_vertical(points, *modifiers, options);
|
||
out.mouse_pan += egui::vec2(-points.x, 0.0);
|
||
} else {
|
||
out.zoom += route_wheel_vertical(points, *modifiers, options);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Combined pinch-to-zoom factor for one frame (1 = no pinch input).
|
||
fn pinch_factor(input: &egui::InputState) -> f32 {
|
||
input
|
||
.events
|
||
.iter()
|
||
.filter_map(|event| {
|
||
let egui::Event::Zoom(f) = event else { return None };
|
||
(*f).is_finite().then_some(*f).filter(|f| *f > 0.0)
|
||
})
|
||
.fold(1.0, |acc, f| acc * f)
|
||
}
|
||
|
||
/// Zoom anchor: the model point under the cursor, else the target's depth.
|
||
/// Preferences › Navigation › Zoom to the cursor (off: toward the view's centre).
|
||
fn zoom_anchor(app: &mut SolveApp, rect: egui::Rect, hover: Option<egui::Pos2>) -> Option<Vec3> {
|
||
hover.filter(|_| app.preferences.zoom_to_cursor).map(|p| {
|
||
let proj = projection(app, rect);
|
||
// Toward the model's surface under the cursor, else the target's depth.
|
||
if let Some(q) = pick_cached(app, &proj, p).iter().find_map(|h| if let Hit::Face { point, .. } = h { Some(*point) } else { None }) {
|
||
return q;
|
||
}
|
||
let (o, d) = proj.ray(p);
|
||
let n = app.cam.back();
|
||
let den = d.dot(n);
|
||
if den.abs() > 1e-9 { o + d * ((app.cam.target - o).dot(n) / den) } else { app.cam.target }
|
||
})
|
||
}
|
||
|
||
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get();
|
||
let rect = ui.max_rect();
|
||
app.viewport.rect = Some(rect);
|
||
refresh_layout(app, ui.ctx().pixels_per_point());
|
||
let resp = ui.interact(rect, ui.id().with("viewport"), Sense::click_and_drag());
|
||
let (hover, scroll, mods, middle, secondary_down, primary_down, primary_pressed, primary_released, delta) = ui.input(|i| {
|
||
(
|
||
i.pointer.hover_pos(),
|
||
i.smooth_scroll_delta.y,
|
||
i.modifiers,
|
||
i.pointer.middle_down(),
|
||
i.pointer.secondary_down(),
|
||
i.pointer.primary_down(),
|
||
i.pointer.primary_pressed(),
|
||
i.pointer.primary_released(),
|
||
i.pointer.delta(),
|
||
)
|
||
});
|
||
// The right button belongs to the marking menu while it is open.
|
||
let secondary_down = secondary_down && app.viewport.context_menu.is_none();
|
||
// The axis view handles its own clicks; the model underneath must not see them.
|
||
let axes = axis_rect(app, rect);
|
||
// Floating windows and menus own input over the model, including wheel navigation. egui's
|
||
// hit test also counts the model as under the pointer a few points past its own edge and
|
||
// inside a window's or a list's edge (#678), so the pointer must be in the viewport too,
|
||
// with no window or menu above it.
|
||
let inside = resp.contains_pointer() && ui.rect_contains_pointer(rect) && hover.is_some_and(|p| !axes.contains(p));
|
||
app.viewport.mouse = hover.filter(|_| inside);
|
||
// Ctrl or Alt held: drawing and dragging don't snap.
|
||
crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command);
|
||
middle_double_click(app, ui, inside);
|
||
|
||
// ---- navigation ----
|
||
let trackpad_pan = app.preferences.trackpad_pan;
|
||
if !app.preferences.smooth_wheel_zoom {
|
||
app.viewport.wheel_zoom_raw_until_idle = true;
|
||
} else if !ui.input(|i| i.is_scrolling()) {
|
||
app.viewport.wheel_zoom_raw_until_idle = false;
|
||
}
|
||
let options = ui.ctx().options(|o| o.input_options);
|
||
let page_height = ui.ctx().viewport_rect().height();
|
||
let raw = ui.input(|i| immediate_wheel(i, options, page_height, &mut app.viewport.trackpad_gesture));
|
||
if trackpad_pan && raw.pan != egui::Vec2::ZERO {
|
||
// Swallow egui's smoothing tail while the trackpad runs (a wheel-only
|
||
// tail ends the moment trackpad input starts; mixed input is rare).
|
||
app.viewport.trackpad_until_idle = true;
|
||
} else if !ui.input(|i| i.is_scrolling()) {
|
||
app.viewport.trackpad_until_idle = false;
|
||
// Self-healing: with no scroll activity no gesture can be mid-flight
|
||
// (its End was lost); a stuck flag would pan future mouse input.
|
||
app.viewport.trackpad_gesture = false;
|
||
}
|
||
let pan = if trackpad_pan { raw.pan } else { egui::Vec2::ZERO } + raw.mouse_pan;
|
||
let scroll = if app.viewport.wheel_zoom_raw_until_idle || (trackpad_pan && app.viewport.trackpad_until_idle) {
|
||
raw.zoom + if trackpad_pan { 0.0 } else { raw.point_zoom }
|
||
} else {
|
||
scroll
|
||
};
|
||
// Pinch-to-zoom (trackpad): direct manipulation, never smoothed.
|
||
let pinch = ui.input(pinch_factor);
|
||
let h = rect.height().max(1.0) as f64;
|
||
if inside && pinch != 1.0 {
|
||
app.viewport.orbit_snap = None;
|
||
app.viewport.orbit_free = None;
|
||
// Spread zooms in: the camera distance shrinks by the pinch factor.
|
||
let f = f64::from(pinch).powf(-crate::prefs::zoom_sign(app));
|
||
let anchor = zoom_anchor(app, rect, hover);
|
||
app.cancel_view_animation();
|
||
app.cam.zoom_at(f, anchor);
|
||
}
|
||
if inside && scroll != 0.0 {
|
||
app.viewport.orbit_snap = None;
|
||
app.viewport.orbit_free = None;
|
||
let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp();
|
||
let anchor = zoom_anchor(app, rect, hover);
|
||
app.cancel_view_animation();
|
||
app.cam.zoom_at(f, anchor);
|
||
}
|
||
// Two-finger scroll pans the model with the fingers.
|
||
if inside && pan != egui::Vec2::ZERO {
|
||
app.viewport.orbit_snap = None;
|
||
app.viewport.orbit_free = None;
|
||
app.cancel_view_animation();
|
||
app.cam.pan(f64::from(pan.x), f64::from(pan.y), h);
|
||
}
|
||
// A left press with the preset's keys (Alias: Shift+Alt) navigates until the button is let
|
||
// go (kept through the release frame, so the end of the drag doesn't select either).
|
||
if primary_pressed && inside {
|
||
let empty_space = |app: &SolveApp| {
|
||
app.viewport.context_menu.is_none()
|
||
&& hover.is_some_and(|p| {
|
||
let proj = projection(app, rect);
|
||
pick(app, &proj, p).is_empty() && crate::dim_view::hit(p).is_none() && crate::sketch_tools::glyph_at(p).is_none()
|
||
})
|
||
};
|
||
let openscad_orbit =
|
||
app.preferences.nav == crate::prefs::OPENSCAD_NAV && app.tool.is_none() && app.dialog.is_none() && !mods.shift && empty_space(app);
|
||
let preset = if app.preferences.nav != crate::prefs::OPENSCAD_NAV || openscad_orbit {
|
||
crate::prefs::nav_mode(app, crate::prefs::NavButton::Left, mods)
|
||
} else {
|
||
None
|
||
};
|
||
// Ctrl+left drag from empty space orbits in every preset (Cmd on macOS). A Ctrl+click
|
||
// still selects, and Ctrl on geometry still drags it without snapping.
|
||
let ctrl_orbit = (mods.ctrl || mods.command) && !mods.shift && !mods.alt && empty_space(app);
|
||
app.viewport.primary_nav = preset.or(ctrl_orbit.then_some(NavMode::Orbit));
|
||
} else if !primary_down && !primary_released {
|
||
app.viewport.primary_nav = None;
|
||
}
|
||
// The fixed navigation mode (navigation bar) or the preset's left-button binding.
|
||
let primary_nav = app.viewport.nav.or(app.viewport.primary_nav);
|
||
let button = if middle {
|
||
Some(crate::prefs::NavButton::Middle)
|
||
} else if secondary_down {
|
||
Some(crate::prefs::NavButton::Right)
|
||
} else {
|
||
None
|
||
};
|
||
let dragging = resp.dragged() || (inside && button.is_some());
|
||
let mode = if dragging && app.viewport.axis_drag.is_none() {
|
||
match button {
|
||
Some(b) => crate::prefs::nav_mode(app, b, mods),
|
||
None if primary_down => primary_nav,
|
||
None => None,
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
if mode == Some(NavMode::Orbit) {
|
||
let press = ui.input(|i| i.pointer.press_origin());
|
||
app.cancel_view_animation();
|
||
// Shift can be required to enter orbit (Fusion/Inventor middle drag, Alias Shift+Alt),
|
||
// rather than an additional request for snapping. Only an extra modifier snaps.
|
||
let without_shift = egui::Modifiers { shift: false, ..mods };
|
||
let snap = app.preferences.nav != crate::prefs::OPENSCAD_NAV
|
||
&& mods.shift
|
||
&& match button {
|
||
Some(b) => crate::prefs::nav_mode(app, b, without_shift) == Some(NavMode::Orbit),
|
||
None => app.viewport.nav.is_some(),
|
||
};
|
||
orbit_drag(app, rect, f64::from(delta.x), f64::from(delta.y), press, snap, true);
|
||
} else {
|
||
if matches!(mode, Some(NavMode::Pan | NavMode::Zoom)) {
|
||
app.viewport.orbit_snap = None;
|
||
}
|
||
if app.viewport.axis_drag.is_none() {
|
||
app.viewport.orbit_free = None;
|
||
}
|
||
if delta != egui::Vec2::ZERO {
|
||
if mode.is_some() {
|
||
app.cancel_view_animation();
|
||
}
|
||
match mode {
|
||
Some(NavMode::Pan) => app.cam.pan(f64::from(delta.x), f64::from(delta.y), h),
|
||
Some(NavMode::Zoom) => app.cam.zoom_at((f64::from(delta.y) * 0.01).exp(), None),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
if !dragging {
|
||
app.viewport.orbit_pivot = None;
|
||
}
|
||
if app.viewport.spin {
|
||
// A click on the navigation bar (its own button) is not a stop.
|
||
let bar = app
|
||
.viewport
|
||
.layout
|
||
.as_ref()
|
||
.map(|l| l.navigation)
|
||
.unwrap_or_else(|| Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(320.0, 30.0)));
|
||
let (pressed, key, dt) = ui.input(|i| {
|
||
(
|
||
i.pointer.any_pressed() && inside && !i.pointer.press_origin().is_some_and(|p| bar.contains(p)),
|
||
i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. })),
|
||
i.stable_dt,
|
||
)
|
||
});
|
||
if pressed || key || dragging {
|
||
app.viewport.spin = false;
|
||
} else {
|
||
app.cam.orbit(f64::from(dt.min(0.1)) * 30.0, 0.0);
|
||
ui.ctx().request_repaint();
|
||
}
|
||
}
|
||
if !app.cam.is_valid() {
|
||
app.viewport.orbit_free = None;
|
||
app.viewport.orbit_snap = None;
|
||
app.cam = Camera::default();
|
||
app.fit_view();
|
||
}
|
||
|
||
step_orbit_snap(app, rect, ui.input(|i| f64::from(i.stable_dt)));
|
||
if app.viewport.orbit_snap.is_some() {
|
||
ui.ctx().request_repaint();
|
||
}
|
||
app.viewport.physical_scale = ui.ctx().pixels_per_point();
|
||
let proj = projection(app, rect);
|
||
if let Ok(mut cache) = app.viewport.display.lock() {
|
||
cache.prepare(app.session.world_state(), &proj, app.viewport.pixels_per_point());
|
||
}
|
||
// Suppress hover for the whole navigation gesture, including frames between pointer
|
||
// events. Testing delta made the highlight reappear whenever a held drag paused.
|
||
// Resolve it before rendering so the GPU scene and screen overlays use the same hit.
|
||
let navigating = scroll != 0.0
|
||
|| pinch != 1.0
|
||
|| pan != egui::Vec2::ZERO
|
||
|| middle
|
||
|| secondary_down
|
||
|| (primary_nav.is_some() && primary_down)
|
||
|| mode.is_some()
|
||
|| app.viewport.axis_drag.is_some()
|
||
|| app.viewport.spin
|
||
|| app.viewport.orbit_snap.is_some()
|
||
|| app.cam_anim.is_some();
|
||
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default();
|
||
// The Fillet and Chamfer window resolves its own corners: the nearest sketch point to a
|
||
// corner drawn from the origin is the fixed origin, which ends no two curves.
|
||
let corner = hover.filter(|_| inside && !navigating).and_then(|p| crate::sketch_modify_ui::corner_hit(app, &proj, p));
|
||
let cand = if app.tool.is_some() {
|
||
hits.first().cloned().map(|h| (h, None))
|
||
} else if let Some((hit, sel)) = corner {
|
||
Some((hit, Some(sel)))
|
||
} else {
|
||
candidate(app, &hits).map(|(h, s)| (h, Some(s)))
|
||
};
|
||
app.viewport.hover = cand.as_ref().map(|c| c.0.clone());
|
||
// Select Other: a press held still opens the list of everything under it; while the list is
|
||
// open its row under the pointer is what lights up.
|
||
let held =
|
||
crate::select_other::track_hold(app, ui.ctx(), &proj, inside, primary_pressed, primary_down, hover, primary_nav.is_some() || mode.is_some());
|
||
crate::select_other::hover_override(app);
|
||
let painter = ui.painter_at(rect);
|
||
// Background gradient.
|
||
let (top, bot) = (t.viewport_top, t.viewport_bottom);
|
||
let mut mesh = egui::Mesh::default();
|
||
mesh.colored_vertex(rect.left_top(), top);
|
||
mesh.colored_vertex(rect.right_top(), top);
|
||
mesh.colored_vertex(rect.right_bottom(), bot);
|
||
mesh.colored_vertex(rect.left_bottom(), bot);
|
||
mesh.add_triangle(0, 1, 2);
|
||
mesh.add_triangle(0, 2, 3);
|
||
painter.add(Shape::mesh(mesh));
|
||
|
||
// ---- model ----
|
||
let key = scene_key(app);
|
||
let t0 = crate::now_ms();
|
||
if let Some(_g) = app.viewport.gpu {
|
||
if key != app.viewport.key {
|
||
let sc = build_scene(app);
|
||
if let Ok(mut slot) = app.viewport.slot.lock() {
|
||
*slot = Some(sc);
|
||
}
|
||
app.viewport.key = key;
|
||
app.viewport.build_ms = crate::now_ms() - t0;
|
||
}
|
||
let sk = sketch_scene_key(app, &proj);
|
||
if sk != app.viewport.sketch_key {
|
||
let scene = build_sketch_scene(app, &proj);
|
||
if let Ok(mut slot) = app.viewport.sketch_slot.lock() {
|
||
*slot = Some(scene);
|
||
}
|
||
app.viewport.sketch_key = sk;
|
||
}
|
||
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(),
|
||
sketch_key: app.viewport.sketch_key,
|
||
sketch_slot: app.viewport.sketch_slot.clone(),
|
||
hl_key: app.viewport.hl_key,
|
||
hl_slot: app.viewport.hl_slot.clone(),
|
||
pv_key: app.preview.key,
|
||
pv_slot: app.preview.slot.clone(),
|
||
clip: section_clip(app),
|
||
cap: t.section_cap.to_normalized_gamma_f32(),
|
||
view_proj: proj.vp.to_f32(),
|
||
back: proj.cam.back().to_f32(),
|
||
size_px: [rect.width() * ppp, rect.height() * ppp],
|
||
images: crate::ref_images::gpu_images(app),
|
||
analysis: analysis_uniform(app),
|
||
access: crate::access::map(app),
|
||
outline: matches!(app.ui.visual_style, 0 | 3),
|
||
eye: {
|
||
let e = proj.cam.eye().to_f32();
|
||
[e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }]
|
||
},
|
||
};
|
||
painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb));
|
||
} else {
|
||
cpu_render(app, ui.ctx(), &painter, rect, &proj);
|
||
}
|
||
|
||
// ---- interaction ----
|
||
let add = mods.shift || mods.command || mods.ctrl;
|
||
crate::sketch_text::set_one_letter(mods.shift);
|
||
if let Some(p) = hover.filter(|_| inside) {
|
||
if app.tool.is_some() {
|
||
crate::tools::on_hover(app, &proj, p);
|
||
}
|
||
if resp.clicked() && !held {
|
||
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((hit, Some(sel))) = cand {
|
||
dialog_pick(app, &hit, sel);
|
||
} else if let Some(mut dialog) = app.dialog.take() {
|
||
let state = app.session.world_state();
|
||
if let Some(sketch) = app.session.active_sketch.and_then(|id| state.sketch(id)) {
|
||
let (origin, direction) = proj.ray(p);
|
||
if let Some(point) = sketch.plane.intersect_ray(origin, direction) {
|
||
crate::sketch_modify_ui::pick_position(&mut dialog, &app.session, point);
|
||
}
|
||
}
|
||
app.dialog = Some(dialog);
|
||
}
|
||
} else {
|
||
// Dimensions and constraint glyphs sit on top of the geometry.
|
||
let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k);
|
||
let sketching = app.session.active_sketch.is_some();
|
||
// A dimension's text wins; its lines only where no sketch point or curve is (a
|
||
// diameter's line runs through the centre point).
|
||
let on_geometry = matches!(cand.as_ref().map(|c| &c.0), Some(Hit::SketchPoint { .. } | Hit::SketchCurve { .. }));
|
||
let on_dim = crate::dim_view::hit_text(p)
|
||
.or_else(|| if on_geometry { None } else { crate::dim_view::hit(p) })
|
||
.filter(|_| sketching && !off("dimensions"));
|
||
let on_glyph = crate::sketch_tools::glyph_at(p).filter(|_| sketching && !off("constraints"));
|
||
if crate::sketch_text_handles::over(app, &proj, p) {
|
||
// A text's resize or rotation handle: the text stays selected.
|
||
} else if !crate::sketch3d::click(app, &proj, p) {
|
||
match on_dim.or(on_glyph) {
|
||
Some(id) => select(app, Some(Sel::SketchConstraint { id }), add),
|
||
// Clicking again on the same spot selects the next thing under it (not
|
||
// the second click of a double-click, which has its own actions).
|
||
None if !add && !resp.double_clicked() && crate::select_other::cycle_click(app, &proj, p) => {}
|
||
None => {
|
||
let sel = cand.and_then(|c| c.1);
|
||
select(app, sel.clone(), add);
|
||
crate::select_other::note_click(app, p, if add { None } else { sel });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Double-click a dimension: edit its value in place.
|
||
let dim_edit = resp.double_clicked() && !held && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p);
|
||
// Double-click a text (an outline or inside a letter; the first click selected it): edit it.
|
||
if resp.double_clicked()
|
||
&& !held
|
||
&& !dim_edit
|
||
&& app.tool.is_none()
|
||
&& app.dialog.is_none()
|
||
&& let Some(entity) = crate::sketch_text::selected_entity(app)
|
||
{
|
||
crate::sketch_text::open(app, &entity);
|
||
}
|
||
// Double-click a sketch curve: select the chain joined to it end to end.
|
||
if resp.double_clicked()
|
||
&& !held
|
||
&& !dim_edit
|
||
&& app.tool.is_none()
|
||
&& app.dialog.is_none()
|
||
&& let Some(Hit::SketchCurve { sketch, id, .. }) = app.viewport.hover.clone()
|
||
&& app.session.active_sketch == Some(sketch)
|
||
&& let Ok(v) = app.session.execute("sketch.chain", &json!({ "curve": id }))
|
||
{
|
||
let items: Vec<Sel> =
|
||
v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str()).map(|id| Sel::SketchCurve { id: id.into() }).collect();
|
||
let _ = app.run("select.set", json!({ "items": items, "add": add }));
|
||
}
|
||
// Double-click an edge: select the edges that continue it smoothly (the loop).
|
||
if resp.double_clicked()
|
||
&& !held
|
||
&& !dim_edit
|
||
&& app.tool.is_none()
|
||
&& app.dialog.is_none()
|
||
&& let Some(Hit::Edge { body, index, .. }) = app.viewport.hover.clone()
|
||
&& let Some(b) = app.session.world_state().body(&body).cloned()
|
||
{
|
||
let m = b.mesh();
|
||
let items: Vec<Sel> = m
|
||
.tangent_chain(index, 2f64.to_radians())
|
||
.into_iter()
|
||
.filter_map(|i| m.edges.get(i).map(|e| Sel::Edge { body: body.clone(), index: i, point: polyline_mid(e) }))
|
||
.collect();
|
||
let _ = app.run("select.set", json!({ "items": items, "add": add }));
|
||
}
|
||
if resp.secondary_clicked() && delta == egui::Vec2::ZERO {
|
||
if app.tool.is_some() {
|
||
crate::tools::finish(app);
|
||
} else if app.dialog.is_none() {
|
||
crate::context_menu::open(app, p);
|
||
}
|
||
}
|
||
}
|
||
// Dragging a sketch point of the active sketch moves it; the solver keeps the constraints.
|
||
if app.tool.is_none() && app.dialog.is_none() && primary_nav.is_none() && mode.is_none() && app.session.active_sketch.is_some() && !held {
|
||
let on_plane = |app: &SolveApp, p: Pos2| {
|
||
let ss = app.session.active_sketch.and_then(|sid| app.session.world_state().sketch(sid).cloned())?;
|
||
let (o, d) = proj.ray(p);
|
||
ss.plane.intersect_ray(o, d).map(|w| ss.plane.to_local(w))
|
||
};
|
||
// What a drag starts on: the 3D triad, then a dimension's text, then sketch geometry
|
||
// (before the dimension's lines, which end on the geometry they measure), as for clicks.
|
||
let grabbed = resp
|
||
.drag_started_by(egui::PointerButton::Primary)
|
||
.then(|| ui.input(|i| i.pointer.press_origin()))
|
||
.flatten()
|
||
.filter(|a| !crate::sketch_text_handles::drag_start(app, &proj, *a) && !crate::sketch3d::drag_start(app, &proj, *a))
|
||
.map(|a| (a, pick(app, &proj, a).into_iter().next()));
|
||
if let Some((a, hit)) = &grabbed
|
||
&& !crate::sketch_text_handles::dragging(app)
|
||
&& let Some(q) = on_plane(app, *a)
|
||
{
|
||
let on_geometry = matches!(hit, Some(Hit::SketchPoint { .. } | Hit::SketchCurve { .. }));
|
||
crate::dim_view::drag_start(app, *a, q, on_geometry);
|
||
}
|
||
if crate::sketch3d::dragging()
|
||
&& (resp.dragged_by(egui::PointerButton::Primary) || resp.drag_stopped())
|
||
&& let Some(p) = hover
|
||
{
|
||
crate::sketch3d::drag_to(app, &proj, p);
|
||
}
|
||
if crate::sketch_text_handles::dragging(app)
|
||
&& (resp.dragged_by(egui::PointerButton::Primary) || resp.drag_stopped())
|
||
&& let Some(p) = hover
|
||
{
|
||
crate::sketch_text_handles::drag_to(app, &proj, p, mods.shift);
|
||
}
|
||
if crate::dim_view::dragging()
|
||
&& (resp.dragged_by(egui::PointerButton::Primary) || resp.drag_stopped())
|
||
&& let Some(q) = hover.and_then(|p| on_plane(app, p))
|
||
{
|
||
crate::dim_view::drag_to(app, q);
|
||
}
|
||
// A point or a curve of the sketch being edited follows a drag that starts on it.
|
||
if let Some((a, hit)) = grabbed
|
||
&& !crate::dim_view::dragging()
|
||
&& !crate::sketch3d::dragging()
|
||
&& !crate::sketch_text_handles::dragging(app)
|
||
{
|
||
let active = app.session.active_sketch;
|
||
match hit {
|
||
Some(Hit::SketchPoint { sketch, id, .. }) if Some(sketch) == active => {
|
||
app.viewport.point_drag = Some((id, app.session.undo.len(), None))
|
||
}
|
||
Some(Hit::SketchCurve { sketch, id, .. }) if Some(sketch) == active => {
|
||
app.viewport.point_drag = on_plane(app, a).map(|q| (id, app.session.undo.len(), Some(q)));
|
||
}
|
||
_ => {}
|
||
}
|
||
if let Some((id, _, _)) = &app.viewport.point_drag {
|
||
let link = app.session.active_sketch.and_then(|sid| app.session.doc.sketch(sid).ok()).and_then(|sk| {
|
||
let link = sk
|
||
.curve_index(id)
|
||
.and_then(|i| sk.curves.get(i))
|
||
.and_then(|c| c.link.as_deref())
|
||
.or_else(|| sk.resolve_point(id).and_then(|i| sk.points.get(i)).and_then(|p| p.link.as_deref()))?;
|
||
sk.link(link).filter(|l| l.kind == solidcraft_engine::sketch::LinkKind::Text).map(|l| l.id.clone())
|
||
});
|
||
if let Some(link) = link
|
||
&& let Some(q) = on_plane(app, a)
|
||
{
|
||
if let Some((_, _, grab)) = &mut app.viewport.point_drag {
|
||
*grab = Some(q);
|
||
}
|
||
if let Ok(result) = app.session.execute("edit.checkpoint", &json!({}))
|
||
&& let Some(checkpoint) = result["checkpoint"].as_u64()
|
||
{
|
||
app.viewport.text_drag = Some((checkpoint, link));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let Some((id, depth, grab)) = app.viewport.point_drag.clone()
|
||
&& resp.dragged_by(egui::PointerButton::Primary)
|
||
&& let Some(mut q) = hover.and_then(|p| on_plane(app, p))
|
||
{
|
||
let from = grab.unwrap_or(q);
|
||
// Snap the dragged point (or the line's ends) to other geometry, like drawing.
|
||
let snap = {
|
||
let st = app.session.world_state();
|
||
app.session.active_sketch.and_then(|s| st.sketch(s)).and_then(|ss| {
|
||
let sk = &ss.sketch;
|
||
let (own, exclude) = crate::drag_snap::moving_points(sk, &id);
|
||
if own.iter().any(|&p| sk.point_locked(p)) {
|
||
return crate::drag_snap::find(sk, &[], &[], 0.0);
|
||
}
|
||
let a = proj.to_screen(ss.plane.to_world(q))?;
|
||
let b = proj.to_screen(ss.plane.to_world(q + Vec2::X))?;
|
||
let mm_px = 1.0 / (a.distance(b) as f64).max(1e-9);
|
||
let d = q - from;
|
||
let moving: Vec<(usize, Vec2)> =
|
||
own.iter().filter_map(|&p| if grab.is_some() { sk.point(p).map(|x| (p, x + d)) } else { Some((p, q)) }).collect();
|
||
crate::drag_snap::find(sk, &moving, &exclude, mm_px)
|
||
})
|
||
};
|
||
if let Some(s) = snap {
|
||
q += s.offset;
|
||
}
|
||
// Each frame re-solves the sketch only (`live`); the model follows when the drag ends.
|
||
let at = match app.session.execute("sketch.drag", &json!({"entity": id, "from": [from.x, from.y], "to": [q.x, q.y], "live": true})) {
|
||
Ok(r) => {
|
||
app.session.undo.truncate(depth + 1);
|
||
r.get("at").and_then(solidcraft_engine::params::vec2).unwrap_or(q)
|
||
}
|
||
Err(_) => from,
|
||
};
|
||
// The grab stays on the geometry: what it could not follow this frame it catches up
|
||
// with on the next one instead of lagging the cursor for the rest of the drag.
|
||
if grab.is_some() {
|
||
app.viewport.point_drag = Some((id, depth, Some(at)));
|
||
}
|
||
}
|
||
if resp.drag_stopped() {
|
||
crate::sketch_text_handles::drag_end(app);
|
||
if let Some((_, depth, _)) = app.viewport.point_drag.clone() {
|
||
// Letting go on a point, midpoint or curve adds the matching constraint, then the
|
||
// whole model follows the drag, all in the drag's undo step.
|
||
let _ = app.session.execute("sketch.drag_end", &json!({ "constraint": crate::drag_snap::release() }));
|
||
app.session.undo.truncate(depth + 1);
|
||
}
|
||
crate::drag_snap::clear();
|
||
app.viewport.point_drag = None;
|
||
crate::sketch_text_move::end_drag(app);
|
||
crate::dim_view::drag_end();
|
||
crate::sketch3d::drag_end();
|
||
}
|
||
}
|
||
// A sketch drag whose release never came (focus lost, a tool or dialog took over) ends here,
|
||
// so the model does not stay behind its sketch until the next command. Not while CAD input
|
||
// is held back (an issue report's capture): a release then belongs to the report, and the
|
||
// drag continues afterwards.
|
||
if app.viewport.point_drag.is_some() && !crate::issue_report::cad_input_blocked() && !ui.input(|i| i.pointer.primary_down()) {
|
||
app.viewport.point_drag = None;
|
||
crate::drag_snap::clear();
|
||
let _ = app.session.execute("sketch.drag_end", &json!({}));
|
||
crate::sketch_text_move::end_drag(app);
|
||
}
|
||
if crate::sketch_text_handles::dragging(app) && !crate::issue_report::cad_input_blocked() && !ui.input(|i| i.pointer.primary_down()) {
|
||
crate::sketch_text_handles::drag_end(app);
|
||
}
|
||
// Box selection: a primary drag on the model when no navigation mode is on.
|
||
if app.tool.is_none()
|
||
&& !held
|
||
&& primary_nav.is_none()
|
||
&& mode.is_none()
|
||
&& app.viewport.point_drag.is_none()
|
||
&& !crate::dim_view::dragging()
|
||
&& !crate::sketch3d::dragging()
|
||
&& !crate::sketch_text_handles::dragging(app)
|
||
{
|
||
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)
|
||
&& !axes.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;
|
||
}
|
||
crate::preview::paint_sketches(app, &painter, &proj);
|
||
crate::ref_images::show(app, ui, &painter, &proj);
|
||
crate::dialogs_assembly::origins(app, &painter, &proj);
|
||
overlays(app, &painter, &proj);
|
||
crate::dim_view::show(app, ui, &painter, &proj);
|
||
crate::sketch_text_handles::show(app, ui, &painter, &proj, hover);
|
||
crate::sketch3d::show(app, &painter, &proj);
|
||
crate::inference::show(app, &painter, &proj);
|
||
crate::drag_snap::show(app, &painter, &proj);
|
||
hover_highlight(app, &painter, &proj);
|
||
points_2d(app, &painter, &proj);
|
||
if let Some((plane, sketch)) = crate::sketch_attachment_ui::lost_guide(app)
|
||
&& let Some((low, high)) = sketch.bounds()
|
||
&& let Some(at) = proj.to_screen(plane.to_world(solidcraft_engine::geom::Vec2::new((low.x + high.x) * 0.5, high.y)))
|
||
{
|
||
painter.text(at - vec2(0.0, 12.0), Align2::CENTER_BOTTOM, "Saved sketch plane — attachment lost", FontId::proportional(12.0), t.warning);
|
||
}
|
||
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);
|
||
}
|
||
crate::canvas::show(app, ui, &painter, &proj);
|
||
crate::sketch_tools::show(app, ui, &painter, &proj);
|
||
crate::sketch_dims::show(app, ui, &proj);
|
||
crate::select_other::show(app, ui.ctx());
|
||
axis_view(app, ui, rect);
|
||
nav_bar(app, ui, rect);
|
||
// Status chip.
|
||
if let Some((msg, at, err)) = app.status.clone() {
|
||
if crate::now_ms() - at < 6000.0 {
|
||
let pos = pos2(rect.left() + 12.0, rect.bottom() - 46.0);
|
||
let galley = painter.layout_no_wrap(msg, FontId::proportional(12.5), if err { t.error } else { t.text });
|
||
let r = Rect::from_min_size(pos, galley.size() + vec2(16.0, 8.0));
|
||
painter.rect_filled(r, 4.0, t.overlay);
|
||
painter.galley(pos + vec2(8.0, 4.0), galley, t.text);
|
||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||
} else {
|
||
app.status = None;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A click without a command: select the candidate (replacing the selection, or toggling it
|
||
/// in with Shift/Ctrl); a click on nothing clears.
|
||
pub(crate) fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
|
||
match sel {
|
||
Some(x) => {
|
||
let items = picked_selection(&app.session, &x);
|
||
if add && items.iter().all(|x| app.session.selection.iter().any(|y| crate::selection::same_item(x, y))) {
|
||
let rest: Vec<Sel> =
|
||
app.session.selection.iter().filter(|y| !items.iter().any(|x| crate::selection::same_item(x, y))).cloned().collect();
|
||
let _ = app.run("select.set", json!({ "items": rest }));
|
||
} else {
|
||
let _ = app.run("select.set", json!({"items": items, "add": add}));
|
||
}
|
||
}
|
||
None if !add => {
|
||
let _ = app.run("select.clear", json!({}));
|
||
}
|
||
None => {}
|
||
}
|
||
}
|
||
|
||
/// What a click on `sel` picks: its pieces ([`grouped_selection`]), and for a letter of a sketch
|
||
/// text the whole text, unless Shift is held (Shift picks the one letter).
|
||
pub(crate) fn picked_selection(s: &solidcraft_engine::Session, sel: &Sel) -> Vec<Sel> {
|
||
if let Sel::Profile { sketch, index } = sel
|
||
&& !crate::sketch_text::one_letter()
|
||
&& let Some(letters) = crate::sketch_text::text_profiles(s, *sketch, *index)
|
||
{
|
||
return letters.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
|
||
}
|
||
grouped_selection(s, sel)
|
||
}
|
||
|
||
/// Expand a visible rim or surface into the kernel pieces a command needs. The original
|
||
/// face pick keeps its point (used by tools such as Hole and construction geometry).
|
||
pub(crate) fn grouped_selection(s: &solidcraft_engine::Session, sel: &Sel) -> Vec<Sel> {
|
||
let st = s.world_state();
|
||
match sel {
|
||
Sel::Edge { body, index, .. } => {
|
||
if let Some(b) = st.body(body) {
|
||
let m = b.mesh();
|
||
return m
|
||
.circular_rim(*index)
|
||
.into_iter()
|
||
.filter_map(|i| m.edges.get(i).map(|e| Sel::Edge { body: body.clone(), index: i, point: polyline_mid(e) }))
|
||
.collect();
|
||
}
|
||
}
|
||
Sel::Face { body, index, .. } => {
|
||
if let Some(b) = st.body(body) {
|
||
let m = b.mesh();
|
||
let mut patch = m.surface_patch(*index);
|
||
patch.sort_by_key(|i| usize::from(i != index));
|
||
return patch
|
||
.into_iter()
|
||
.filter_map(|i| {
|
||
if i == *index {
|
||
return Some(sel.clone());
|
||
}
|
||
m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| {
|
||
if *f as usize != i {
|
||
return None;
|
||
}
|
||
let [a, b, c] = m.tri(t)?;
|
||
Some(Sel::Face { body: body.clone(), index: i, point: (a + b + c) / 3.0 })
|
||
})
|
||
})
|
||
.collect();
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
vec![sel.clone()]
|
||
}
|
||
|
||
/// Measure keeps one entry per visible item, rather than using its two slots for kernel halves.
|
||
pub(crate) fn measurement_selection(s: &solidcraft_engine::Session, sel: &Sel) -> Sel {
|
||
let pieces = grouped_selection(s, sel);
|
||
let mut representative = sel.clone();
|
||
match &mut representative {
|
||
Sel::Edge { index, .. } => {
|
||
if let Some(Sel::Edge { index: i, .. }) = pieces.first() {
|
||
*index = *i;
|
||
}
|
||
}
|
||
Sel::Face { index, .. } => {
|
||
if let Some(i) = pieces
|
||
.iter()
|
||
.filter_map(|p| match p {
|
||
Sel::Face { index, .. } => Some(*index),
|
||
_ => None,
|
||
})
|
||
.min()
|
||
{
|
||
*index = i;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
representative
|
||
}
|
||
|
||
/// 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.world_state();
|
||
let mut out: Vec<Sel> = Vec::new();
|
||
let to2 = |pts: &[Vec3]| -> Vec<Pos2> { pts.iter().filter_map(|p| proj.to_screen(*p)).collect() };
|
||
let sketch_dialog = app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::SketchModify(_)));
|
||
let accepts = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept);
|
||
if (app.dialog.is_none() || sketch_dialog)
|
||
&& let Some(ss) = app.session.active_sketch.and_then(|sid| st.sketch(sid))
|
||
{
|
||
// What the selection filter lets through (window: wholly inside; crossing: touching).
|
||
let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k);
|
||
for (i, c) in ss.sketch.curves.iter().enumerate().filter(|_| !off("sketch") && !off("sketch_curves")) {
|
||
if accepts
|
||
.is_some_and(|a| a & crate::selection::CURVES == 0 && (a & crate::selection::AXES == 0 || !matches!(c.kind, CurveKind::Line { .. })))
|
||
{
|
||
continue;
|
||
}
|
||
if !solidcraft_engine::view::sketch_curve_visible(&ss.sketch, i) {
|
||
continue;
|
||
}
|
||
let sampled = if bx.crossing {
|
||
crate::sketch_display::curve_sample(app, ss.feature, i, &ss.plane, proj)
|
||
} else {
|
||
std::sync::Arc::new(crate::sketch_display::curve_unclipped(&ss.sketch, i, &ss.plane, proj, app.viewport.pixels_per_point()))
|
||
};
|
||
let parts: Vec<Vec<Pos2>> = sampled.parts.iter().map(|part| to2(part)).collect();
|
||
let takes = if bx.crossing {
|
||
parts.iter().any(|part| bx.takes(part))
|
||
} else {
|
||
sampled.quality == solidcraft_engine::render::SamplingQuality::Complete
|
||
&& !parts.is_empty()
|
||
&& parts.iter().all(|part| bx.takes(part))
|
||
};
|
||
if takes {
|
||
out.push(Sel::SketchCurve { id: c.id.clone() });
|
||
}
|
||
}
|
||
let visible = solidcraft_engine::view::SketchPointVisibility::new(&ss.sketch);
|
||
for (i, p) in ss.sketch.points.iter().enumerate().filter(|_| !off("sketch") && !off("sketch_points")) {
|
||
if accepts.is_none_or(|a| a & crate::selection::SKETCH_POINTS != 0)
|
||
&& i != 0
|
||
&& visible.takes(p)
|
||
&& bx.takes(&to2(&[ss.plane.to_world(p.pos)]))
|
||
{
|
||
out.push(Sel::SketchPoint { id: p.id.clone() });
|
||
}
|
||
}
|
||
if !sketch_dialog && !off("dimensions") {
|
||
for (id, r) in crate::dim_view::text_rects() {
|
||
if bx.takes(&[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()]) {
|
||
out.push(Sel::SketchConstraint { id });
|
||
}
|
||
}
|
||
}
|
||
if !sketch_dialog && !off("constraints") {
|
||
for (id, c) in crate::sketch_tools::glyph_positions() {
|
||
if bx.takes(&[c]) {
|
||
out.push(Sel::SketchConstraint { id });
|
||
}
|
||
}
|
||
}
|
||
} 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.session.body_visible(&b.name) {
|
||
continue;
|
||
}
|
||
let m = b.mesh();
|
||
if accept & EDGES != 0 {
|
||
for (ei, e) in m.edges.iter().enumerate() {
|
||
let rim = m.circular_rim(ei);
|
||
let takes = if bx.crossing { bx.takes(&to2(e)) } else { rim.iter().all(|&i| m.edges.get(i).is_some_and(|p| bx.takes(&to2(p)))) };
|
||
if m.edge_pickable(ei) && takes {
|
||
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);
|
||
let takes = bx.takes(&to2(&pts))
|
||
&& (bx.crossing
|
||
|| m.surface_patch(f).iter().all(|&face| {
|
||
m.face_edges(u32::try_from(face).unwrap_or(u32::MAX))
|
||
.iter()
|
||
.all(|&e| m.edges.get(e).is_some_and(|p| bx.takes(&to2(p))))
|
||
}));
|
||
if let Some(point) = point
|
||
&& takes
|
||
{
|
||
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 parts = crate::sketch_display::boundary_parts(&p.region.outer, &ss.plane, proj, app.viewport.pixels_per_point());
|
||
let takes = if bx.crossing {
|
||
parts.iter().any(|part| bx.takes(&to2(part)))
|
||
} else {
|
||
crate::sketch_display::profile_region(app, sid, pi, &ss.plane, proj).as_ref().as_ref().is_some_and(|display| {
|
||
let points: Vec<_> = display.outer.segs.iter().map(|segment| ss.plane.to_world(segment.start())).collect();
|
||
bx.takes(&to2(&points))
|
||
})
|
||
};
|
||
if takes {
|
||
out.push(Sel::Profile { sketch: sid, index: pi });
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
let mut grouped = Vec::new();
|
||
for item in out {
|
||
for piece in grouped_selection(&app.session, &item) {
|
||
if !grouped.iter().any(|x| crate::selection::same_item(x, &piece)) {
|
||
grouped.push(piece);
|
||
}
|
||
}
|
||
}
|
||
let out = grouped;
|
||
if let Some(mut d) = app.dialog.take() {
|
||
d.take_box(&app.session, 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.session.visibility.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.world_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)
|
||
{
|
||
for part in &crate::sketch_display::curve_sample(app, *sketch, ci, &ss.plane, proj).parts {
|
||
let mode = ss.sketch.curves.get(ci).map_or(0, |c| if c.centerline { 2 } else { u8::from(c.construction) });
|
||
crate::tools::paint_sketch_path(part, mode, painter, proj, Stroke::new(3.0, col));
|
||
}
|
||
}
|
||
}
|
||
Some(Hit::SketchPoint { sketch, at, .. }) => {
|
||
if let Some(ss) = st.sketch(*sketch)
|
||
&& let Some(p) = proj.to_screen(ss.plane.to_world(*at))
|
||
{
|
||
painter.circle_stroke(p, 6.0, Stroke::new(2.0, col));
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// Sketch points and the status of the active sketch (constraint glyphs: `sketch_tools`).
|
||
fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||
let t = Tokens::get();
|
||
let st = app.session.world_state();
|
||
for item in &app.session.selection {
|
||
if let Sel::ExternalSketchPoint { sketch, id } = item
|
||
&& let Some(ss) = st.sketch(*sketch)
|
||
&& let Some(point) = ss.sketch.resolve_point(id).and_then(|i| ss.sketch.point(i))
|
||
&& let Some(pos) = proj.to_screen(ss.plane.to_world(point))
|
||
{
|
||
painter.circle_stroke(pos, 6.0, Stroke::new(2.0, t.sel_edge));
|
||
}
|
||
}
|
||
let Some(sid) = app.session.active_sketch else { return };
|
||
let Some(ss) = st.sketch(sid) else { return };
|
||
let sk = &ss.sketch;
|
||
let locked = sk.locked_points();
|
||
let virtual_corners: Vec<usize> = sk.virtual_corners().into_iter().map(|(i, _)| i).collect();
|
||
let visible = solidcraft_engine::view::SketchPointVisibility::new(sk);
|
||
for (i, p) in sk.points.iter().enumerate() {
|
||
if !visible.takes(p) {
|
||
continue;
|
||
}
|
||
let hidden = (sk.view.hide_points && i != 0) || (sk.view.hide_projected && p.link.is_some());
|
||
let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)).filter(|_| !hidden) else { continue };
|
||
let det = ss.report.point_determined.get(i).copied().unwrap_or(false);
|
||
let c = if p.link.is_none() && locked.get(i).copied().unwrap_or(false) {
|
||
t.sketch_locked
|
||
} else if det {
|
||
t.sketch_point
|
||
} else {
|
||
t.sketch_point_free
|
||
};
|
||
if i == 0 {
|
||
painter.circle(sp, 3.5, Color32::from_rgb(240, 200, 60), Stroke::new(1.0, t.body_edge));
|
||
} else if virtual_corners.binary_search(&i).is_ok() {
|
||
// A virtual sharp corner: an open square, it is not on any drawn curve.
|
||
painter.rect_stroke(Rect::from_center_size(sp, vec2(6.0, 6.0)), 0.0, Stroke::new(1.2, c), egui::StrokeKind::Middle);
|
||
} else {
|
||
painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c);
|
||
}
|
||
}
|
||
// Sketch status.
|
||
if let Some(r) = app.viewport.rect {
|
||
let msg = if !ss.report.ok() {
|
||
format!("{} — constraints conflict", ss.name)
|
||
} else if ss.report.dof == 0 {
|
||
format!("{} — fully constrained", ss.name)
|
||
} else {
|
||
format!("{} — {} degrees of freedom", ss.name, ss.report.dof)
|
||
};
|
||
let c = if ss.report.ok() { t.sketch_accent } else { t.error };
|
||
painter.text(pos2(r.left() + 12.0, r.top() + 12.0), Align2::LEFT_TOP, msg, FontId::proportional(13.0), c);
|
||
}
|
||
}
|
||
|
||
fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, rect: Rect, proj: &Proj) {
|
||
if app.viewport.no_pixels {
|
||
return;
|
||
}
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
sketch_scene_key(app, proj).hash(&mut h);
|
||
for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.roll, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] {
|
||
v.to_bits().hash(&mut h);
|
||
}
|
||
(rect.width() as u32, rect.height() as u32).hash(&mut h);
|
||
let key = h.finish();
|
||
if app.viewport.cpu.as_ref().is_none_or(|(k, _)| *k != key) {
|
||
let mut budget = solidcraft_engine::render::SamplingBudget::new(131_072, 1_048_576);
|
||
let scene =
|
||
solidcraft_engine::view::scene_with_section_and_sketch_sampler(&app.session, &proj.cam, section_plane(app), |sk, index, plane| {
|
||
crate::sketch_display::curve_with_budget(sk, index, plane, proj, app.viewport.pixels_per_point(), &mut budget).parts
|
||
});
|
||
let w = (rect.width() as usize).clamp(16, 4096);
|
||
let hh = (rect.height() as usize).clamp(16, 4096);
|
||
let c = solidcraft_engine::render::render(&scene, &proj.cam, w, hh);
|
||
let img = egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba);
|
||
let tex = ctx.load_texture("sc_cpu_viewport", img, egui::TextureOptions::LINEAR);
|
||
app.viewport.cpu = Some((key, tex));
|
||
}
|
||
if let Some((_, tex)) = &app.viewport.cpu {
|
||
painter.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn refresh_layout(app: &mut SolveApp, scale: f32) {
|
||
if let Some(rect) = app.viewport.rect {
|
||
let docks: Vec<_> =
|
||
[app.viewport.palette_rect.filter(|_| app.session.active_sketch.is_some()), app.viewport.dialog_rect.filter(|_| app.dialog.is_some())]
|
||
.into_iter()
|
||
.flatten()
|
||
.collect();
|
||
app.viewport.layout = Some(crate::viewport_layout::Layout::new(rect, &docks, scale, app.preferences.navigator_style));
|
||
}
|
||
}
|
||
|
||
/// Orientation controls share the layout reservation used by model fitting.
|
||
fn axis_center(app: &SolveApp, rect: Rect) -> Pos2 {
|
||
axis_rect(app, rect).center()
|
||
}
|
||
|
||
fn axis_rect(app: &SolveApp, rect: Rect) -> Rect {
|
||
app.viewport.layout.as_ref().filter(|l| l.viewport == rect).map(|l| l.axis_view).unwrap_or_else(|| {
|
||
let size = axis_view_size(app.preferences.navigator_style);
|
||
Rect::from_min_size(pos2(rect.right() - size, rect.top()), vec2(size, size))
|
||
})
|
||
}
|
||
|
||
const AXIS_LENGTH: f64 = 38.0;
|
||
use crate::icons::navigation::AXIS_RADIUS;
|
||
|
||
/// Six signed world axes, sorted back to front so overlapping tips have one owner.
|
||
fn axis_tips(cam: &Camera, c: Pos2) -> Vec<(&'static str, Vec3, Pos2, f64)> {
|
||
let (r, u, b) = cam.basis();
|
||
let mut tips: Vec<_> = [("x", Vec3::X), ("-x", -Vec3::X), ("y", Vec3::Y), ("-y", -Vec3::Y), ("z", Vec3::Z), ("-z", -Vec3::Z)]
|
||
.into_iter()
|
||
.map(|(name, d)| (name, d, c + vec2((d.dot(r) * AXIS_LENGTH) as f32, -(d.dot(u) * AXIS_LENGTH) as f32), d.dot(b)))
|
||
.collect();
|
||
tips.sort_by(|a, b| a.3.total_cmp(&b.3));
|
||
tips
|
||
}
|
||
|
||
fn axis_hit(tips: &[(&str, Vec3, Pos2, f64)], p: Pos2) -> Option<usize> {
|
||
tips.iter().enumerate().rev().find(|(_, (_, _, at, _))| at.distance(p) <= AXIS_RADIUS).map(|(i, _)| i)
|
||
}
|
||
|
||
/// Six principal views, twelve edge diagonals and eight isometric corner directions.
|
||
fn snap_directions() -> impl Iterator<Item = Vec3> {
|
||
[-1.0, 0.0, 1.0].into_iter().flat_map(|x| {
|
||
[-1.0, 0.0, 1.0].into_iter().flat_map(move |y| [-1.0, 0.0, 1.0].into_iter().filter_map(move |z| Vec3::new(x, y, z).normalized()))
|
||
})
|
||
}
|
||
|
||
/// Snap to the nearest standard or diagonal direction in the user's up-axis frame.
|
||
fn snapped_axis_camera(cam: Camera) -> Camera {
|
||
let b = cam.to_frame(cam.back());
|
||
let dir = snap_directions().max_by(|a, c| a.dot(b).total_cmp(&c.dot(b))).unwrap_or(Vec3::Z);
|
||
let mut to = cam.looking_from(cam.from_frame(dir));
|
||
if dir.x == 0.0 && dir.y == 0.0 {
|
||
to.yaw = nearest_quarter_turn(cam.yaw);
|
||
}
|
||
to
|
||
}
|
||
|
||
pub(crate) struct OrbitSnap {
|
||
target: Camera,
|
||
yaw_velocity: f64,
|
||
pitch_velocity: f64,
|
||
pivot: Option<(Vec3, Pos2)>,
|
||
}
|
||
|
||
/// Pointer motion accumulates independently of the spring preview, so many small deltas
|
||
/// still cross snap boundaries. Keep a small angular margin to avoid boundary flicker.
|
||
fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press: Option<Pos2>, snap: bool, around_cursor: bool) {
|
||
let displayed = app.cam;
|
||
if let Some(free) = app.viewport.orbit_free.take() {
|
||
app.cam = free;
|
||
}
|
||
if around_cursor {
|
||
crate::prefs::orbit(app, rect, dx, dy, press);
|
||
} else {
|
||
app.cam.orbit(dx, dy);
|
||
}
|
||
let free = app.cam;
|
||
app.viewport.orbit_free = Some(free);
|
||
if snap || !around_cursor || app.viewport.orbit_snap.is_some() {
|
||
let mut target = if snap { snapped_axis_camera(free) } else { free };
|
||
if snap && let Some(previous) = app.viewport.orbit_snap.as_ref() {
|
||
let b = free.back();
|
||
if previous.target.back().dot(b) + 0.025 >= target.back().dot(b) {
|
||
target.yaw = previous.target.yaw;
|
||
target.pitch = previous.target.pitch;
|
||
}
|
||
}
|
||
let pivot = app.viewport.orbit_pivot.filter(|_| around_cursor).and_then(|p| {
|
||
app.cam = displayed;
|
||
projection(app, rect).to_screen(p).map(|at| (p, at))
|
||
});
|
||
let spring = app.viewport.orbit_snap.get_or_insert(OrbitSnap { target, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot });
|
||
spring.target = target;
|
||
spring.pivot = pivot;
|
||
app.cam = displayed;
|
||
}
|
||
}
|
||
|
||
/// A lightly damped spring: mostly settled in about a third of a second, with a small
|
||
/// elastic finish. Bounded substeps keep the behavior consistent across frame rates.
|
||
fn step_orbit_snap(app: &mut SolveApp, rect: Rect, dt: f64) {
|
||
let Some(mut spring) = app.viewport.orbit_snap.take() else { return };
|
||
let dt = if dt.is_finite() { dt.clamp(0.0, 0.05) } else { 1.0 / 60.0 };
|
||
let steps = (dt * 240.0).ceil().max(1.0) as usize;
|
||
let h = dt / steps as f64;
|
||
let omega = 16.0;
|
||
let damping = 0.82;
|
||
let yaw_error = |yaw: f64| (spring.target.yaw - yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
|
||
for _ in 0..steps.min(12) {
|
||
spring.yaw_velocity += (omega * omega * yaw_error(app.cam.yaw) - 2.0 * damping * omega * spring.yaw_velocity) * h;
|
||
spring.pitch_velocity += (omega * omega * (spring.target.pitch - app.cam.pitch) - 2.0 * damping * omega * spring.pitch_velocity) * h;
|
||
app.cam.yaw += spring.yaw_velocity * h;
|
||
app.cam.pitch = (app.cam.pitch + spring.pitch_velocity * h).clamp(-std::f64::consts::FRAC_PI_2, std::f64::consts::FRAC_PI_2);
|
||
}
|
||
app.cam.target = spring.target.target;
|
||
let done = yaw_error(app.cam.yaw).abs() < 1e-5
|
||
&& (spring.target.pitch - app.cam.pitch).abs() < 1e-5
|
||
&& spring.yaw_velocity.abs() < 1e-4
|
||
&& spring.pitch_velocity.abs() < 1e-4;
|
||
if done {
|
||
app.cam = spring.target;
|
||
}
|
||
if let Some((pivot, at)) = spring.pivot
|
||
&& let Some(after) = projection(app, rect).to_screen(pivot)
|
||
{
|
||
app.cam.pan(f64::from(at.x - after.x), f64::from(at.y - after.y), f64::from(rect.height().max(1.0)));
|
||
}
|
||
if !done {
|
||
app.viewport.orbit_snap = Some(spring);
|
||
}
|
||
}
|
||
|
||
/// Dragging anywhere on the axis gizmo orbits about the view centre. A stationary press
|
||
/// on a tip chooses its view. Floating windows own their input over the gizmo.
|
||
fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool {
|
||
let (pressed, down, decided, origin, pos, shift) = ui.input(|i| {
|
||
let p = &i.pointer;
|
||
(p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos(), i.modifiers.shift)
|
||
});
|
||
if pressed && ui.rect_contains_pointer(rect) {
|
||
app.viewport.axis_drag = origin.filter(|p| match app.preferences.navigator_style {
|
||
crate::prefs::NavigatorStyle::Axes => axis_rect(app, rect).contains(*p),
|
||
crate::prefs::NavigatorStyle::Cube => {
|
||
navigation_cube::drag_origin(&app.cam, axis_center(app, rect), *p, axis_rect(app, rect).width() / navigation_cube::DESIGN_SIZE)
|
||
}
|
||
});
|
||
if app.viewport.axis_drag.is_some() {
|
||
app.viewport.orbit_free = None;
|
||
}
|
||
}
|
||
if !down && app.viewport.axis_drag.take().is_some() {
|
||
app.viewport.orbit_free = None;
|
||
}
|
||
let Some(last) = app.viewport.axis_drag.filter(|_| decided) else { return false };
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||
if let Some(p) = pos {
|
||
app.cancel_view_animation();
|
||
orbit_drag(app, rect, f64::from(p.x - last.x), f64::from(p.y - last.y), None, shift, false);
|
||
app.viewport.axis_drag = Some(p);
|
||
ui.ctx().request_repaint();
|
||
}
|
||
true
|
||
}
|
||
|
||
fn axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||
match app.preferences.navigator_style {
|
||
crate::prefs::NavigatorStyle::Axes => signed_axis_view(app, ui, rect),
|
||
crate::prefs::NavigatorStyle::Cube => navigation_cube::show(app, ui, rect),
|
||
}
|
||
}
|
||
|
||
fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||
if !axis_rect(app, rect).is_positive() {
|
||
app.viewport.axis_drag = None;
|
||
crate::scenario::publish_count("axis_view_highlights", 0.0);
|
||
return;
|
||
}
|
||
crate::scenario::publish_rect("axis_view:outer", axis_rect(app, rect));
|
||
let t = Tokens::get();
|
||
let c = axis_center(app, rect);
|
||
let orbiting = axis_view_orbit(app, ui, rect);
|
||
let tips = axis_tips(&app.cam, c);
|
||
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect));
|
||
let hovered = if orbiting { None } else { hover.and_then(|p| axis_hit(&tips, p)) };
|
||
let painter = ui.painter_at(rect);
|
||
if orbiting || hover.is_some_and(|p| axis_rect(app, rect).contains(p)) {
|
||
painter.circle_filled(c, 55.0, t.overlay);
|
||
}
|
||
for (i, (name, d, at, depth)) in tips.iter().enumerate() {
|
||
crate::icons::navigation::axis_tip(&painter, c, name, d, *at, *depth, hovered == Some(i), t);
|
||
crate::scenario::publish_handle(&format!("axis_view:{name}"), *at);
|
||
}
|
||
crate::scenario::publish_count("axis_view_highlights", f64::from(u8::from(hovered.is_some())));
|
||
if let Some(i) = hovered
|
||
&& let Some((name, d, _, _)) = tips.get(i)
|
||
{
|
||
crate::scenario::publish_handle(&format!("axis_view_hover:{name}"), hover.unwrap_or(c));
|
||
ui.interact(Rect::from_center_size(hover.unwrap_or(c), vec2(2.0, 2.0)), ui.id().with("axis_view_tip"), Sense::hover())
|
||
.on_hover_text(format!("View from {}. Click again to reverse. Drag to orbit; hold Shift to snap.", name.to_uppercase()));
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||
if ui.input(|i| i.pointer.primary_clicked()) {
|
||
let frame = app.cam.to_frame(*d);
|
||
let dir = if app.cam.back().dot(*d) > 0.9999 { -*d } else { *d };
|
||
let mut to = app.cam.looking_from(dir);
|
||
if frame.z.abs() > 0.5 {
|
||
to.yaw = nearest_quarter_turn(app.cam.yaw);
|
||
}
|
||
if app.preferences.look_at_anim {
|
||
app.cancel_view_animation();
|
||
app.viewport.orbit_free = None;
|
||
app.viewport.orbit_snap = Some(OrbitSnap { target: to, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot: None });
|
||
ui.ctx().request_repaint();
|
||
} else {
|
||
app.animate_to(to);
|
||
}
|
||
}
|
||
}
|
||
let home = Rect::from_center_size(pos2(c.x - 44.0, c.y - 44.0), vec2(18.0, 18.0));
|
||
crate::scenario::publish_rect("axis_view:home", home);
|
||
crate::scenario::publish_handle("axis_view:home", home.center());
|
||
let hr = ui.interact(home, ui.id().with("axis_view_home"), Sense::click());
|
||
icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { t.overlay }, t.accent);
|
||
if hr.on_hover_text(crate::i18n::tr_label("Home view")).clicked() {
|
||
app.animate_view("home");
|
||
}
|
||
}
|
||
|
||
/// A middle-button double-click goes Home (or fits, or nothing: Preferences › Navigation). Only
|
||
/// clicks count: a press that moves (a pan or orbit drag) or is held is not one, and the two
|
||
/// clicks must both be the middle button's, close together in time and place.
|
||
fn middle_double_click(app: &mut SolveApp, ui: &egui::Ui, inside: bool) {
|
||
use egui::PointerButton::{Extra1, Extra2, Middle, Primary, Secondary};
|
||
let (double, releases, middle, other, dragging) = ui.input(|i| {
|
||
(
|
||
i.pointer.button_double_clicked(Middle),
|
||
i.events.iter().filter(|e| matches!(e, egui::Event::PointerButton { button: Middle, pressed: false, .. })).count(),
|
||
i.pointer.button_clicked(Middle),
|
||
[Primary, Secondary, Extra1, Extra2].into_iter().any(|b| i.pointer.button_clicked(b)),
|
||
i.pointer.is_decidedly_dragging(),
|
||
)
|
||
});
|
||
// egui pairs clicks of any buttons; the first click must have been the middle one too (or
|
||
// both arrive in this frame).
|
||
let go = double && inside && !other && (app.viewport.middle_clicked || releases >= 2);
|
||
app.viewport.middle_clicked = if other || dragging || go { false } else { middle || app.viewport.middle_clicked };
|
||
if !go {
|
||
return;
|
||
}
|
||
match crate::prefs::MiddleDoubleClick::of(app) {
|
||
crate::prefs::MiddleDoubleClick::Home => app.animate_view("home"),
|
||
crate::prefs::MiddleDoubleClick::Fit => app.animate_fit_all(),
|
||
crate::prefs::MiddleDoubleClick::Off => {}
|
||
}
|
||
}
|
||
|
||
/// At a halfway tie, prefer the square heading nearer the standard plan view.
|
||
fn nearest_quarter_turn(yaw: f64) -> f64 {
|
||
if !yaw.is_finite() {
|
||
return 0.0;
|
||
}
|
||
let q = std::f64::consts::FRAC_PI_2;
|
||
let lo = (yaw / q).floor() * q;
|
||
let hi = lo + q;
|
||
let (dlo, dhi) = (yaw - lo, hi - yaw);
|
||
if (dlo - dhi).abs() < 1e-6 {
|
||
let off_front = |a: f64| ((a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI).abs();
|
||
if off_front(lo) <= off_front(hi) { lo } else { hi }
|
||
} else if dlo < dhi {
|
||
lo
|
||
} else {
|
||
hi
|
||
}
|
||
}
|
||
|
||
/// Navigation bar (bottom centre).
|
||
/// Turn the view to face the selected planar face or plane, else the active sketch.
|
||
pub fn look_at_selection(app: &mut SolveApp) {
|
||
let target = app.session.selection.iter().find_map(|s| match s {
|
||
Sel::Face { body, index, point } => crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| (*point, n)),
|
||
Sel::Plane { name } => solidcraft_engine::geom::Plane::named(name).map(|p| (p.origin, p.normal())),
|
||
_ => None,
|
||
});
|
||
match target {
|
||
Some((p, n)) => {
|
||
let mut to = app.cam.looking_from(n);
|
||
to.target = p;
|
||
app.animate_to(to);
|
||
}
|
||
None => crate::dialogs::look_at_sketch(app),
|
||
}
|
||
}
|
||
|
||
fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||
if app.viewport.layout.as_ref().is_some_and(|l| !l.navigation.is_positive()) {
|
||
return;
|
||
}
|
||
let t = Tokens::get();
|
||
let items: [(&str, &str); 10] = [
|
||
(
|
||
"orbit",
|
||
if app.preferences.nav == crate::prefs::OPENSCAD_NAV {
|
||
"Orbit (left drag on empty space; Shift+left drag box-selects)"
|
||
} else {
|
||
"Orbit (right drag; hold Shift to snap)"
|
||
},
|
||
),
|
||
("spin", "Continuous orbit (stops at the next click or key)"),
|
||
("pan", "Pan (drag; or middle drag)"),
|
||
("zoom", "Zoom (drag; or wheel)"),
|
||
("fit", "Fit (F6)"),
|
||
("lookat", "Look At the selected face or plane (or the active sketch)"),
|
||
("home", "Home view"),
|
||
("perspective", "Perspective / orthographic"),
|
||
("style", "Visual style: shaded with edges, shaded, wireframe, shaded with hidden edges"),
|
||
("settings", "View settings: grid, ground shadow, agent cursor"),
|
||
];
|
||
let menu_id = ui.id().with("nav_view_menu");
|
||
let mut menu_open = ui.data(|d| d.get_temp::<bool>(menu_id)).unwrap_or(false);
|
||
let w = items.len() as f32 * 30.0 + 10.0;
|
||
let bar = app
|
||
.viewport
|
||
.layout
|
||
.as_ref()
|
||
.map(|l| l.navigation)
|
||
.unwrap_or_else(|| Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0)));
|
||
crate::scenario::publish_rect("nav:outer", bar);
|
||
let active = ui.rect_contains_pointer(bar)
|
||
|| ui.rect_contains_pointer(axis_rect(app, rect))
|
||
|| app.viewport.axis_drag.is_some()
|
||
|| app.viewport.nav.is_some()
|
||
|| app.viewport.spin
|
||
|| menu_open;
|
||
let mut painter = ui.painter_at(rect);
|
||
painter.set_opacity(if active || app.preferences.navigator_style == crate::prefs::NavigatorStyle::Axes { 1.0 } else { 0.45 });
|
||
painter.rect(bar, 6.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||
let columns = ((bar.width() - 10.0) / 30.0).round().clamp(1.0, 10.0) as usize;
|
||
for (i, (icon, tip)) in items.iter().enumerate() {
|
||
let br = Rect::from_min_size(
|
||
pos2(bar.left() + 5.0 + (i % columns) as f32 * 30.0, bar.top() + 2.0 + (i / columns) as f32 * 30.0),
|
||
vec2(26.0, 26.0),
|
||
);
|
||
let resp = ui.interact(br, ui.id().with(("nav", *icon)), Sense::click());
|
||
let active = match *icon {
|
||
"orbit" => app.viewport.nav == Some(NavMode::Orbit),
|
||
"pan" => app.viewport.nav == Some(NavMode::Pan),
|
||
"zoom" => app.viewport.nav == Some(NavMode::Zoom),
|
||
"perspective" => app.ui.perspective,
|
||
"settings" => menu_open,
|
||
"spin" => app.viewport.spin,
|
||
_ => false,
|
||
};
|
||
if active || resp.hovered() {
|
||
painter.rect_filled(br, 4.0, if active { t.accent_soft } else { t.hover });
|
||
}
|
||
icons::paint(&painter, br.shrink(4.0), icon, t.icon, t.icon_fill, t.accent);
|
||
if resp.on_hover_text(*tip).clicked() {
|
||
let toggle = |m: NavMode, cur: Option<NavMode>| if cur == Some(m) { None } else { Some(m) };
|
||
match *icon {
|
||
"orbit" => app.viewport.nav = toggle(NavMode::Orbit, app.viewport.nav),
|
||
"pan" => app.viewport.nav = toggle(NavMode::Pan, app.viewport.nav),
|
||
"zoom" => app.viewport.nav = toggle(NavMode::Zoom, app.viewport.nav),
|
||
"fit" => app.animate_view("fit"),
|
||
"spin" => {
|
||
app.cancel_view_animation();
|
||
app.viewport.spin = !app.viewport.spin;
|
||
}
|
||
"home" => app.animate_view("home"),
|
||
"lookat" => look_at_selection(app),
|
||
"style" => app.ui.visual_style = (app.ui.visual_style + 1) % 4,
|
||
"perspective" => app.ui.perspective = !app.ui.perspective,
|
||
_ => menu_open = !menu_open,
|
||
}
|
||
}
|
||
if *icon == "settings" {
|
||
crate::scenario::publish_handle("nav:view", br.center());
|
||
if menu_open {
|
||
menu_open = view_menu(app, ui, br);
|
||
}
|
||
}
|
||
}
|
||
ui.data_mut(|d| d.insert_temp(menu_id, menu_open));
|
||
}
|
||
|
||
/// The View settings above the navigation bar's last button: grid, ground shadow and the agent
|
||
/// cursor (#35). False once it should close (a click outside it).
|
||
fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
|
||
let area = crate::frame::area(egui::Id::new("sc_view_menu"))
|
||
.order(egui::Order::Foreground)
|
||
.pivot(Align2::RIGHT_BOTTOM)
|
||
.fixed_pos(button.right_top() - vec2(0.0, 6.0));
|
||
let r = area.show(ui.ctx(), |ui| {
|
||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||
ui.set_width(220.0);
|
||
ui.label(egui::RichText::new(crate::i18n::tr_label("VIEW")).strong().size(11.5));
|
||
ui.checkbox(&mut app.ui.show_grid, crate::i18n::tr_label("Grid"));
|
||
ui.checkbox(&mut app.ui.ground_shadow, crate::i18n::tr_label("Ground shadow"));
|
||
ui.separator();
|
||
let s = &mut app.ui.agent_cursor;
|
||
let show = ui.checkbox(&mut s.show, crate::i18n::tr_label("Show Agent Cursor"));
|
||
crate::scenario::publish_handle("view:agent_cursor", show.rect.center());
|
||
ui.add_enabled_ui(s.show, |ui| {
|
||
ui.horizontal(|ui| {
|
||
ui.label(crate::i18n::tr_label("Speed"));
|
||
crate::agent_cursor::speed_slider(ui, &mut s.speed);
|
||
});
|
||
ui.checkbox(&mut s.follow_camera, crate::i18n::tr_label("Follow camera (Look At sketches)"));
|
||
});
|
||
});
|
||
});
|
||
let clicked_out = ui.input(|i| i.pointer.any_pressed())
|
||
&& ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !r.response.rect.contains(p) && !button.contains(p));
|
||
!clicked_out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Bodies drawn from their display plans, evaluated on the GPU, look like the same bodies
|
||
/// built into triangles on the CPU: the sample design rendered both ways through the
|
||
/// viewport's real pipelines, offscreen, on this machine's GPU (skipped without one).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn bodies_evaluated_on_the_gpu_look_like_bodies_built_on_the_cpu() {
|
||
let mut s = solidcraft_engine::Session::default();
|
||
s.run_script(&solidcraft_engine::sample::script()).unwrap();
|
||
let mut app = SolveApp::new(s, crate::Services::default());
|
||
app.viewport.no_pixels = true;
|
||
app.fit_view();
|
||
const SIZE: u32 = 256;
|
||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32));
|
||
let mut render = |tess: bool| {
|
||
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess });
|
||
let scene = build_scene(&app);
|
||
let proj = projection(&app, rect);
|
||
let e = proj.cam.eye().to_f32();
|
||
let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }];
|
||
let planned = scene.planned.len();
|
||
(planned, scene.tris.len(), crate::gpu::render_offscreen(scene, proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, tess))
|
||
};
|
||
let (planned, gpu_tris, gpu) = render(true);
|
||
let (none, cpu_tris, cpu) = render(false);
|
||
let (Some(gpu), Some(cpu)) = (gpu, cpu) else {
|
||
eprintln!("skipping: no GPU adapter");
|
||
return;
|
||
};
|
||
assert!(planned > 0 && none == 0, "{planned} bodies planned");
|
||
assert!(gpu_tris < cpu_tris, "the planned bodies' triangles are not built on the CPU ({gpu_tris} vs {cpu_tris} bytes)");
|
||
let px = |img: &[u8], i: usize| [img[4 * i], img[4 * i + 1], img[4 * i + 2]];
|
||
let n = (SIZE * SIZE) as usize;
|
||
let drawn = (0..n).filter(|&i| px(&cpu, i) != [255, 255, 255]).count();
|
||
assert!(drawn > n / 20, "the model covers the view: {drawn} pixels");
|
||
let differ = (0..n).filter(|&i| px(&gpu, i).iter().zip(px(&cpu, i)).any(|(a, b)| a.abs_diff(b) > 24)).count();
|
||
assert!(differ * 100 < drawn, "{differ} of {drawn} model pixels differ");
|
||
}
|
||
|
||
#[test]
|
||
fn construction_drawing_and_hover_overlays_preserve_visible_gaps() {
|
||
fn length(shape: &Shape) -> f32 {
|
||
match shape {
|
||
Shape::LineSegment { points, .. } => points[0].distance(points[1]),
|
||
Shape::Path(path) => path.points.windows(2).map(|w| w[0].distance(w[1])).sum(),
|
||
Shape::Vec(shapes) => shapes.iter().map(length).sum(),
|
||
_ => 0.0,
|
||
}
|
||
}
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||
h.call("ui.view", json!({"view":"top"}));
|
||
h.settle();
|
||
let proj = display_proj(&h.app);
|
||
let a = Vec2::ZERO;
|
||
let b = Vec2::new(20.0, 8.0);
|
||
let whole = proj.to_screen(Vec3::new(b.x, b.y, 0.0)).unwrap().distance(proj.to_screen(Vec3::ZERO).unwrap());
|
||
let mut draw = crate::tools::Tool::for_command("sketch.line").unwrap();
|
||
draw.pts.push((a, None));
|
||
draw.hover = Some((b, None));
|
||
for mode in [0, 1, 2] {
|
||
crate::sketch_palette::set_mode(&mut h.app, mode);
|
||
let ctx = egui::Context::default();
|
||
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||
let painter = ui.ctx().layer_painter(egui::LayerId::background());
|
||
crate::tools::preview(&h.app, &draw, &painter, &proj);
|
||
});
|
||
let preview: f32 = output.shapes.iter().map(|s| length(&s.shape)).sum();
|
||
let r = h.app.run("sketch.line", json!({"points":[[0,0],[20,8]]})).unwrap();
|
||
let id = r["curves"][0].as_str().unwrap().to_string();
|
||
h.app.viewport.hover = Some(Hit::SketchCurve { sketch: h.app.session.active_sketch.unwrap(), id, straight: true });
|
||
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||
hover_highlight(&h.app, &ui.ctx().layer_painter(egui::LayerId::background()), &proj);
|
||
});
|
||
let hover: f32 = output.shapes.iter().map(|s| length(&s.shape)).sum();
|
||
assert!((preview - hover).abs() < 0.01, "mode {mode}: preview {preview}, hover {hover}");
|
||
if mode == 0 {
|
||
assert!((preview - whole).abs() < 0.01);
|
||
} else {
|
||
assert!(preview > whole * 0.25 && preview < whole * 0.85, "mode {mode} covers {preview} of {whole}");
|
||
}
|
||
h.app.run("edit.undo", json!({})).unwrap();
|
||
}
|
||
crate::sketch_palette::set_mode(&mut h.app, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn cpu_viewport_uses_section_preview_and_sketch_slice() {
|
||
fn pixels(h: &mut crate::scenario::Harness, cam: Camera) -> Vec<Color32> {
|
||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(320.0, 240.0));
|
||
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
|
||
h.app.viewport.no_pixels = false;
|
||
// Force a texture upload so this test reads the actual CPU rasterizer result.
|
||
h.app.viewport.cpu = None;
|
||
let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| {
|
||
cpu_render(&mut h.app, ui.ctx(), ui.painter(), rect, &proj);
|
||
});
|
||
let id = h.app.viewport.cpu.as_ref().unwrap().1.id();
|
||
let upload = out.textures_delta.set.get(&id).unwrap().iter().find(|delta| delta.pos.is_none()).unwrap();
|
||
let egui::ImageData::Color(image) = &upload.image;
|
||
assert_eq!(image.size, [320, 240]);
|
||
let pixels = image.pixels.clone();
|
||
out.textures_delta.clear();
|
||
pixels
|
||
}
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap();
|
||
let cam = solidcraft_engine::view::home_camera(&h.app.session);
|
||
let whole = pixels(&mut h, cam);
|
||
let before = h.app.session.doc.clone();
|
||
let undo = h.app.session.undo.len();
|
||
h.app.session.selection = vec![Sel::Plane { name: "XZ".into() }];
|
||
h.app.start("inspect.section");
|
||
let Some(crate::dialogs::Kind::Section { offset, .. }) = h.app.dialog.as_mut().map(|d| &mut d.kind) else { panic!("section dialog") };
|
||
*offset = "15 mm".into();
|
||
assert!(h.app.session.section.is_none());
|
||
let preview = pixels(&mut h, cam);
|
||
assert!(whole.iter().zip(&preview).filter(|(a, b)| a != b).count() > 100, "CPU viewport ignored uncommitted section preview");
|
||
assert!(std::sync::Arc::ptr_eq(&before, &h.app.session.doc));
|
||
assert_eq!(h.app.session.undo.len(), undo);
|
||
h.app.run("inspect.section", json!({"plane":"XZ","offset":15})).unwrap();
|
||
h.app.dialog = None;
|
||
assert_eq!(preview, pixels(&mut h, cam), "preview and committed section must rasterize identically");
|
||
|
||
h.app.session.section = None;
|
||
h.app.run("sketch.create", json!({"plane":{"origin":[0,0,10],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
|
||
let whole = pixels(&mut h, cam);
|
||
h.app.run("sketch.options", json!({"slice":true})).unwrap();
|
||
assert!(h.app.session.section.is_none());
|
||
let sliced = pixels(&mut h, cam);
|
||
assert!(whole.iter().zip(&sliced).filter(|(a, b)| a != b).count() > 100, "CPU viewport ignored active Sketch Slice");
|
||
h.app.run("sketch.options", json!({"slice":false})).unwrap();
|
||
assert_eq!(whole, pixels(&mut h, cam), "disabling Slice restores the whole-body pixels");
|
||
}
|
||
|
||
#[test]
|
||
fn timeline_modifiers_highlight_on_hover_and_click() {
|
||
for command in ["solid.fillet", "solid.chamfer"] {
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.app.ui.show_origin = false;
|
||
let base = h.app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap()["feature"].as_u64().unwrap();
|
||
let id = h.app.run(command, json!({"edges":[[0,0,10]],"radius":2,"distance":2,"name":"Blend"})).unwrap()["feature"].as_u64().unwrap();
|
||
h.app.run("solid.box", json!({"length":5,"width":5,"height":5,"corner":[40,0,0]})).unwrap();
|
||
h.frames(2);
|
||
let geometry = feature_geometry(&h.app, id);
|
||
assert_eq!(geometry.len(), 1, "{command}: {geometry:?}");
|
||
let Sel::Face { body, index, .. } = &geometry[0] else { panic!("a modifier must highlight its face") };
|
||
let st = h.app.session.world_state();
|
||
let m = st.body(body).unwrap().mesh();
|
||
let triangles = m.tri_face.iter().filter(|f| **f as usize == *index).count();
|
||
assert!(triangles > 0 && triangles < m.triangles.len());
|
||
let p = crate::scenario::handle_at("timeline:Blend").unwrap();
|
||
h.step(&json!({"move":[p.x,p.y]})).unwrap();
|
||
assert_eq!(h.app.viewport.hover_feature, Some(id));
|
||
assert_eq!(build_highlight(&h.app).tris.len(), triangles * 3 * crate::gpu::TRI_SIZE);
|
||
assert!(!build_highlight(&h.app).lines.is_empty());
|
||
h.step(&json!({"click":[p.x,p.y]})).unwrap();
|
||
assert_eq!(h.app.session.selection, vec![Sel::Feature { id }]);
|
||
// Moving away removes hover but leaves the selected blend highlighted.
|
||
h.step(&json!({"move":[5,5]})).unwrap();
|
||
assert_eq!(h.app.viewport.hover_feature, None);
|
||
assert_eq!(build_highlight(&h.app).tris.len(), triangles * 3 * crate::gpu::TRI_SIZE);
|
||
let bounds = selection_bounds(&h.app).unwrap();
|
||
assert!(bounds.max.x <= 2.01 && bounds.max.y <= 2.01 && bounds.max.z > 19.99, "{bounds:?}");
|
||
// Creator features retain whole-body feedback, while hidden bodies stay hidden.
|
||
assert_eq!(feature_geometry(&h.app, base), vec![Sel::Body { name: body.clone() }]);
|
||
h.app.session.visibility.hidden_bodies.push(body.clone());
|
||
assert!(feature_geometry(&h.app, id).is_empty());
|
||
assert!(selection_bounds(&h.app).is_none());
|
||
assert!(build_highlight(&h.app).tris.is_empty());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn timeline_modifier_highlight_follows_edits_and_rollback() {
|
||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||
app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap();
|
||
let id = app.run("solid.fillet", json!({"edges":[[0,0,10]],"radius":2})).unwrap()["feature"].as_u64().unwrap();
|
||
// A later chamfer changes the same body without consuming the fillet.
|
||
app.run("solid.chamfer", json!({"edges":[[20,20,10]],"distance":1})).unwrap();
|
||
app.run("solid.move", json!({"bodies":["Body1"],"translate":[30,0,0]})).unwrap();
|
||
app.run("body.rename", json!({"body":"Body1","name":"Moved"})).unwrap();
|
||
app.session.selection = vec![Sel::Feature { id }];
|
||
let bounds = selection_bounds(&app).unwrap();
|
||
assert!(bounds.min.x >= 29.99 && bounds.max.x <= 32.01, "{bounds:?}");
|
||
assert!(feature_geometry(&app, id).iter().all(|x| matches!(x, Sel::Face { body, .. } if body == "Moved")));
|
||
app.run("timeline.roll_to", json!({"position":1})).unwrap();
|
||
assert!(feature_geometry(&app, id).is_empty());
|
||
app.run("timeline.roll_to", json!({})).unwrap();
|
||
assert!(!feature_geometry(&app, id).is_empty());
|
||
app.run("timeline.suppress", json!({"feature":id})).unwrap();
|
||
assert!(feature_geometry(&app, id).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn timeline_modifier_highlight_follows_component_instances() {
|
||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||
app.run("component.create", json!({"name":"Part"})).unwrap();
|
||
app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap();
|
||
let id = app.run("solid.fillet", json!({"edges":[[0,0,10]],"radius":2})).unwrap()["feature"].as_u64().unwrap();
|
||
app.run("occurrence.move", json!({"occurrence":"Part:1","translate":[30,0,0]})).unwrap();
|
||
app.run("occurrence.copy", json!({"component":"Part","translate":[0,40,0]})).unwrap();
|
||
let geometry = feature_geometry(&app, id);
|
||
assert_eq!(geometry.len(), 2, "both occurrences retain the fillet's face: {geometry:?}");
|
||
app.session.selection = vec![Sel::Feature { id }];
|
||
let bounds = selection_bounds(&app).unwrap();
|
||
assert!(bounds.min.x >= 29.99 && bounds.max.x <= 32.01 && bounds.max.y >= 41.99, "{bounds:?}");
|
||
}
|
||
|
||
fn hover_frame(app: &mut SolveApp, ctx: &egui::Context, events: Vec<egui::Event>) {
|
||
let selected = app.highlighted();
|
||
let mut output = ctx.run_ui(
|
||
egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() },
|
||
|ui| {
|
||
show(app, ui);
|
||
},
|
||
);
|
||
// This headless frame checks interactions without applying textures to a renderer.
|
||
output.textures_delta.clear();
|
||
// Click selection is applied after rendering; hover itself must already be current.
|
||
if app.viewport.gpu.is_some() && selected == app.highlighted() {
|
||
assert_eq!(app.viewport.hl_key, highlight_key(app), "GPU highlight must use this frame's hover");
|
||
}
|
||
}
|
||
|
||
fn hover_app(gpu: bool) -> SolveApp {
|
||
let mut s = solidcraft_engine::Session::default();
|
||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||
s.execute("sketch.point", &json!({"point": [0, 0]})).unwrap();
|
||
let mut app = SolveApp::new(s, crate::Services::default());
|
||
app.viewport.no_pixels = true;
|
||
if gpu {
|
||
// The callback is queued by egui; no device is needed to inspect scene updates.
|
||
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false });
|
||
}
|
||
app
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_left_drag_from_empty_space_orbits_in_every_preset() {
|
||
for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) {
|
||
for action in ["orbit", "tool", "point", "click"] {
|
||
let ctx = egui::Context::default();
|
||
let mut app = hover_app(false);
|
||
app.preferences.nav = preset.into();
|
||
app.ui.show_origin = false;
|
||
hover_frame(&mut app, &ctx, vec![]);
|
||
let point = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
|
||
let (start, end) = match action {
|
||
"point" => (point, point + vec2(30.0, 20.0)),
|
||
"click" => (point, point),
|
||
_ => (point - vec2(60.0, 60.0), point - vec2(20.0, 30.0)),
|
||
};
|
||
if action == "tool" {
|
||
app.start("sketch.line");
|
||
}
|
||
let before = app.cam;
|
||
let undo = app.session.undo.len();
|
||
let modifiers = egui::Modifiers { ctrl: true, command: !cfg!(target_os = "macos"), ..Default::default() };
|
||
let frame = |app: &mut SolveApp, mut events: Vec<egui::Event>| {
|
||
events.insert(0, egui::Event::ModifiersChanged(modifiers));
|
||
hover_frame(app, &ctx, events);
|
||
};
|
||
frame(
|
||
&mut app,
|
||
vec![
|
||
egui::Event::PointerMoved(start),
|
||
egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers },
|
||
],
|
||
);
|
||
frame(&mut app, vec![egui::Event::PointerMoved(end)]);
|
||
frame(&mut app, vec![]);
|
||
match action {
|
||
"orbit" | "tool" => {
|
||
assert_ne!(app.cam, before, "{preset}: Ctrl+left drag on empty space orbits ({action})");
|
||
assert!(app.viewport.boxsel.is_none(), "{preset}: no box selection while orbiting");
|
||
}
|
||
"point" => {
|
||
assert_eq!(app.cam, before, "{preset}: Ctrl+drag on sketch geometry is not an orbit");
|
||
assert!(app.viewport.point_drag.is_some(), "{preset}: Ctrl+drag still drags the point");
|
||
}
|
||
_ => assert_eq!(app.cam, before, "{preset}: a Ctrl+click does not orbit"),
|
||
}
|
||
frame(&mut app, vec![egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers }]);
|
||
frame(&mut app, vec![]);
|
||
match action {
|
||
"click" => assert!(!app.session.selection.is_empty(), "{preset}: Ctrl+click still selects"),
|
||
"tool" => assert_eq!(app.session.undo.len(), undo, "{preset}: the orbit drag adds no geometry"),
|
||
"orbit" => assert!(app.session.selection.is_empty(), "{preset}: the end of the orbit selects nothing"),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn openscad_left_drag_preserves_editing_and_selection() {
|
||
for action in ["point", "box", "tool"] {
|
||
let ctx = egui::Context::default();
|
||
let mut app = hover_app(false);
|
||
app.preferences.nav = crate::prefs::OPENSCAD_NAV.into();
|
||
app.ui.show_origin = false;
|
||
hover_frame(&mut app, &ctx, vec![]);
|
||
let point = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
|
||
let (start, end) =
|
||
if action == "point" { (point, point + vec2(30.0, 20.0)) } else { (point - vec2(40.0, 40.0), point + vec2(40.0, 40.0)) };
|
||
if action == "tool" {
|
||
app.start("sketch.line");
|
||
assert!(app.tool.is_some());
|
||
}
|
||
let before = app.cam;
|
||
let undo = app.session.undo.len();
|
||
let modifiers = egui::Modifiers { shift: action == "box", ..Default::default() };
|
||
let frame = |app: &mut SolveApp, mut events: Vec<egui::Event>| {
|
||
events.insert(0, egui::Event::ModifiersChanged(modifiers));
|
||
let mut out = ctx.run_ui(
|
||
egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() },
|
||
|ui| show(app, ui),
|
||
);
|
||
out.textures_delta.clear();
|
||
};
|
||
frame(
|
||
&mut app,
|
||
vec![
|
||
egui::Event::PointerMoved(start),
|
||
egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers },
|
||
],
|
||
);
|
||
frame(&mut app, vec![egui::Event::PointerMoved(end)]);
|
||
frame(&mut app, vec![]);
|
||
assert_eq!(app.cam, before, "{action} drag must not orbit");
|
||
if action == "point" {
|
||
assert!(app.viewport.point_drag.is_some());
|
||
assert_eq!(app.session.undo.len(), undo + 1);
|
||
} else if action == "box" {
|
||
assert!(app.viewport.boxsel.is_some());
|
||
} else {
|
||
assert!(app.tool.is_some());
|
||
assert!(app.viewport.primary_nav.is_none());
|
||
}
|
||
frame(&mut app, vec![egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers }]);
|
||
if action == "box" {
|
||
assert!(!app.session.selection.is_empty());
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn openscad_empty_space_drags_navigate_without_editing_or_box_selecting() {
|
||
for (button, shift) in [
|
||
(egui::PointerButton::Primary, false),
|
||
(egui::PointerButton::Secondary, false),
|
||
(egui::PointerButton::Middle, false),
|
||
(egui::PointerButton::Secondary, true),
|
||
(egui::PointerButton::Middle, true),
|
||
] {
|
||
let ctx = egui::Context::default();
|
||
let mut app = hover_app(false);
|
||
app.preferences.nav = crate::prefs::OPENSCAD_NAV.into();
|
||
hover_frame(&mut app, &ctx, vec![]);
|
||
let point = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
|
||
let p = if button == egui::PointerButton::Primary { point + vec2(80.0, 80.0) } else { point };
|
||
let before = app.cam;
|
||
let undo = app.session.undo.len();
|
||
let modifiers = egui::Modifiers { shift, ..Default::default() };
|
||
let frame = |app: &mut SolveApp, mut events: Vec<egui::Event>| {
|
||
events.insert(0, egui::Event::ModifiersChanged(modifiers));
|
||
let _ = ctx.run_ui(
|
||
egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() },
|
||
|ui| show(app, ui),
|
||
);
|
||
};
|
||
frame(&mut app, vec![egui::Event::PointerMoved(p), egui::Event::PointerButton { pos: p, button, pressed: true, modifiers }]);
|
||
let end = p + vec2(30.0, 20.0);
|
||
frame(&mut app, vec![egui::Event::PointerMoved(end)]);
|
||
frame(&mut app, vec![]);
|
||
assert!(app.viewport.hover.is_none());
|
||
assert!(app.viewport.boxsel.is_none() && app.viewport.point_drag.is_none());
|
||
frame(&mut app, vec![egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }]);
|
||
assert_eq!(app.session.undo.len(), undo);
|
||
assert!(app.session.selection.is_empty());
|
||
assert!(app.viewport.context_menu.is_none());
|
||
if button == egui::PointerButton::Primary {
|
||
assert_ne!(app.cam.yaw, before.yaw);
|
||
assert_eq!(app.cam.target, before.target);
|
||
assert_eq!(app.cam.distance, before.distance);
|
||
} else if shift {
|
||
assert_ne!(app.cam.distance, before.distance);
|
||
assert_eq!(app.cam.target, before.target);
|
||
assert_eq!(app.cam.yaw, before.yaw);
|
||
} else {
|
||
assert_ne!(app.cam.target, before.target);
|
||
assert_eq!(app.cam.distance, before.distance);
|
||
assert_eq!(app.cam.yaw, before.yaw);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn hover_stays_off_between_navigation_events_and_returns_on_release() {
|
||
for gpu in [false, true] {
|
||
for button in [egui::PointerButton::Middle, egui::PointerButton::Secondary, egui::PointerButton::Primary] {
|
||
let ctx = egui::Context::default();
|
||
let mut app = hover_app(gpu);
|
||
if button == egui::PointerButton::Primary {
|
||
app.viewport.nav = Some(NavMode::Orbit);
|
||
}
|
||
hover_frame(&mut app, &ctx, vec![]);
|
||
let p = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
|
||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
|
||
assert!(app.viewport.hover.is_some());
|
||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerButton { pos: p, button, pressed: true, modifiers: Default::default() }]);
|
||
assert!(app.viewport.hover.is_none(), "hover on navigation press");
|
||
for offset in [1.0, 2.0, 3.0] {
|
||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(offset, 0.0))]);
|
||
assert!(app.viewport.hover.is_none(), "hover while moving");
|
||
for _ in 0..3 {
|
||
hover_frame(&mut app, &ctx, vec![]);
|
||
assert!(app.viewport.hover.is_none(), "hover between movement events");
|
||
}
|
||
}
|
||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerButton { pos: p, button, pressed: false, modifiers: Default::default() }]);
|
||
assert!(app.viewport.hover.is_some(), "hover must return on release");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn hover_stays_off_during_spin_and_animated_views() {
|
||
let ctx = egui::Context::default();
|
||
let mut app = hover_app(true);
|
||
hover_frame(&mut app, &ctx, vec![]);
|
||
let p = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
|
||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
|
||
assert!(app.viewport.hover.is_some());
|
||
app.viewport.spin = true;
|
||
for _ in 0..3 {
|
||
hover_frame(&mut app, &ctx, vec![]);
|
||
assert!(app.viewport.hover.is_none());
|
||
}
|
||
app.viewport.spin = false;
|
||
hover_frame(&mut app, &ctx, vec![]);
|
||
assert!(app.viewport.hover.is_some());
|
||
app.cam_anim = Some(solidcraft_engine::render::CameraAnim::new(app.cam, app.cam, 0.0));
|
||
for _ in 0..3 {
|
||
hover_frame(&mut app, &ctx, vec![]);
|
||
assert!(app.viewport.hover.is_none());
|
||
}
|
||
app.cam_anim = None;
|
||
hover_frame(&mut app, &ctx, vec![]);
|
||
assert!(app.viewport.hover.is_some());
|
||
}
|
||
|
||
fn cylinder_app() -> SolveApp {
|
||
let mut s = solidcraft_engine::Session::default();
|
||
s.execute("solid.cylinder", &json!({"radius": 10, "height": 20})).unwrap();
|
||
let mut app = SolveApp::new(s, crate::Services::default());
|
||
app.ui.show_origin = false;
|
||
app
|
||
}
|
||
|
||
#[test]
|
||
fn smooth_wall_display_preserves_wireframe_and_edge_selection() {
|
||
let mut s = solidcraft_engine::Session::default();
|
||
let sketch = s.execute("sketch.create", &json!({"plane":"XY"})).unwrap()["sketch"].as_u64().unwrap();
|
||
s.execute("sketch.ellipse", &json!({"center":[0,0],"major":[25,0],"minor_radius":5})).unwrap();
|
||
let inner =
|
||
s.execute("sketch.offset", &json!({"curves":["e1"],"distance":-1,"chain":false})).unwrap()["curves"][0].as_str().unwrap().to_string();
|
||
let outer =
|
||
s.execute("sketch.offset", &json!({"curves":[inner],"distance":-0.5,"chain":false})).unwrap()["curves"][0].as_str().unwrap().to_string();
|
||
s.execute(
|
||
"solid.extrude",
|
||
&json!({"sketch":sketch,"distance":3,"profiles":{"type":"whole_boundaries","profiles":[{"outer":[outer],"holes":[[inner]]}]}}),
|
||
)
|
||
.unwrap();
|
||
let mut app = SolveApp::new(s, crate::Services::default());
|
||
app.ui.ground_shadow = false;
|
||
let st = app.session.world_state();
|
||
let b = st.bodies.first().unwrap();
|
||
let mesh = b.mesh();
|
||
assert_eq!(mesh.edges.len(), 1536);
|
||
assert_eq!(mesh.smooth_edges.iter().filter(|f| **f).count(), 512);
|
||
let smooth = mesh.smooth_edges.iter().position(|f| *f).unwrap();
|
||
let expected = mesh.edges.iter().enumerate().filter(|(i, _)| mesh.shaded_edge_visible(*i)).map(|(_, e)| e.len() - 1).sum::<usize>();
|
||
let shaded = build_scene(&app);
|
||
assert_eq!(shaded.lines.len() / crate::gpu::LINE_SIZE, expected);
|
||
assert!(!shaded.tris.is_empty());
|
||
app.ui.visual_style = 2;
|
||
let wire = build_scene(&app);
|
||
let expected_wire =
|
||
mesh.edges.iter().enumerate().filter(|(i, _)| !mesh.seams.get(*i).copied().unwrap_or(false)).map(|(_, e)| e.len() - 1).sum::<usize>();
|
||
assert_eq!(wire.lines.len() / crate::gpu::LINE_SIZE, expected_wire);
|
||
assert!(expected_wire > expected);
|
||
assert!(wire.tris.is_empty());
|
||
app.ui.visual_style = 0;
|
||
app.session.selection = vec![Sel::Edge { body: b.name.clone(), index: smooth, point: polyline_mid(&mesh.edges[smooth]) }];
|
||
assert!(!build_highlight(&app).lines.is_empty());
|
||
let mut without_display_mask = (*mesh).clone();
|
||
without_display_mask.smooth_edges.clear();
|
||
assert_eq!(mesh.surface_patch(mesh.edge_faces[smooth][0] as usize), without_display_mask.surface_patch(mesh.edge_faces[smooth][0] as usize));
|
||
let cam = solidcraft_engine::view::home_camera(&app.session);
|
||
let cpu = solidcraft_engine::view::scene(&app.session, &cam);
|
||
let actual_edges = cpu.lines.iter().filter(|line| line.color == solidcraft_engine::view::colors::EDGE).count();
|
||
assert_eq!(actual_edges, mesh.edges.iter().enumerate().filter(|(i, _)| mesh.shaded_edge_visible(*i)).count());
|
||
if let Ok(directory) = solidcraft_engine::geom::env::var("SMOOTH_SCREENSHOTS").ok_or(()) {
|
||
std::fs::create_dir_all(&directory).unwrap();
|
||
for (name, old_edges, wireframe) in [("after", false, false), ("before", true, false), ("wireframe", true, true)] {
|
||
let mut scene = solidcraft_engine::view::scene(&app.session, &cam);
|
||
scene.lines.retain(|line| line.color == solidcraft_engine::view::colors::EDGE);
|
||
if old_edges {
|
||
scene.lines.clear();
|
||
for (i, edge) in mesh.edges.iter().enumerate() {
|
||
if mesh.edge_pickable(i) {
|
||
scene.lines.push(solidcraft_engine::render::SceneLine {
|
||
points: edge.clone(),
|
||
color: solidcraft_engine::view::colors::EDGE,
|
||
width: 1.2,
|
||
on_top: false,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
if wireframe {
|
||
scene.meshes.clear();
|
||
}
|
||
let png = solidcraft_engine::render::render_png(&scene, &cam, 1200, 800).unwrap();
|
||
std::fs::write(std::path::Path::new(&directory).join(format!("smooth-ring-{name}.png")), png).unwrap();
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn certified_small_crease_overrides_old_seam_fit_for_picking_and_patches() {
|
||
let mut s = solidcraft_engine::Session::default();
|
||
let sketch = s.execute("sketch.create", &json!({"plane":"XY"})).unwrap()["sketch"].as_u64().unwrap();
|
||
let step = std::f64::consts::TAU / 8.;
|
||
let k = 4. / 3. * (step / 4.).tan() * 10.;
|
||
for i in 0..8 {
|
||
let a = i as f64 * step;
|
||
let b = (i + 1) as f64 * step;
|
||
let p0 = [10. * a.cos(), 10. * a.sin()];
|
||
let p3 = if i == 7 { [10., 0.] } else { [10. * b.cos(), 10. * b.sin()] };
|
||
let mut p1 = [p0[0] - k * a.sin(), p0[1] + k * a.cos()];
|
||
let mut p2 = [p3[0] + k * b.sin(), p3[1] - k * b.cos()];
|
||
if i == 0 {
|
||
p1[0] += k * 1e-6;
|
||
p2[0] += k * 1e-6 * b.cos();
|
||
p2[1] += k * 1e-6 * b.sin();
|
||
}
|
||
s.execute("sketch.spline.control_point", &json!({"points":[p0,p1,p2,p3]})).unwrap();
|
||
}
|
||
s.execute("solid.extrude", &json!({"sketch":sketch,"distance":3})).unwrap();
|
||
let mut app = SolveApp::new(s, crate::Services::default());
|
||
let st = app.session.world_state();
|
||
let body = st.bodies.first().unwrap();
|
||
let mesh = body.mesh();
|
||
let crease = mesh
|
||
.edges
|
||
.iter()
|
||
.position(|line| {
|
||
line.first().zip(line.last()).is_some_and(|(a, b)| {
|
||
(a.x - 10.).abs() < 1e-8 && a.y.abs() < 1e-8 && (b.x - 10.).abs() < 1e-8 && b.y.abs() < 1e-8 && (a.z - b.z).abs() > 2.9
|
||
})
|
||
})
|
||
.unwrap();
|
||
assert!(mesh.seams[crease], "the fixture must exercise the coarse seam false positive");
|
||
assert!(mesh.sharp_edges[crease]);
|
||
assert!(mesh.shaded_edge_visible(crease));
|
||
assert!(mesh.edge_pickable(crease));
|
||
assert!(mesh.edge_faces[crease].iter().any(|f| mesh.surface_patch(*f as usize).len() == 1));
|
||
let mid = polyline_mid(&mesh.edges[crease]);
|
||
app.cam = app.cam.looking_from(Vec3::new(30., 0., 1.5));
|
||
app.cam.target = Vec3::new(0., 0., 1.5);
|
||
for perspective in [false, true] {
|
||
app.ui.perspective = perspective;
|
||
let proj = projection(&app, Rect::from_min_size(pos2(0., 0.), vec2(800., 600.)));
|
||
let hits = pick(&app, &proj, proj.to_screen(mid).unwrap());
|
||
assert!(hits.iter().any(|hit| matches!(hit,Hit::Edge{index,..} if *index==crease)), "{hits:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn circular_items_measure_as_whole_rims_and_surfaces() {
|
||
let mut app = cylinder_app();
|
||
let st = app.session.world_state();
|
||
let b = &st.bodies[0];
|
||
let m = b.mesh();
|
||
let edges: Vec<Sel> = m
|
||
.edges
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, p)| p.len() > 3 && p.iter().all(|q| (q.z - 20.0).abs() < 1e-6))
|
||
.map(|(index, p)| Sel::Edge { body: b.name.clone(), index, point: polyline_mid(p) })
|
||
.collect();
|
||
let result = app.session.measure_items(&edges);
|
||
assert_eq!(result["items"].as_array().unwrap().len(), 1);
|
||
assert!((result["items"][0]["length_mm"].as_f64().unwrap() - 20.0 * std::f64::consts::PI).abs() < 0.2, "{result}");
|
||
app.session.selection = edges;
|
||
let d = crate::dialogs::Dialog::for_command(&app, "inspect.measure").unwrap();
|
||
assert_eq!(d.items().len(), 1);
|
||
|
||
let faces = m.edge_faces.iter().zip(&m.seams).find(|(_, seam)| **seam).unwrap().0;
|
||
let wall = Sel::Face { body: b.name.clone(), index: faces[0] as usize, point: Vec3::new(10.0, 0.0, 10.0) };
|
||
let wall_pieces = grouped_selection(&app.session, &wall);
|
||
let result = app.session.measure_items(&wall_pieces);
|
||
assert_eq!(result["items"].as_array().unwrap().len(), 1);
|
||
assert!((result["items"][0]["area_mm2"].as_f64().unwrap() - 400.0 * std::f64::consts::PI).abs() < 10.0, "{result}");
|
||
let mut d = crate::dialogs::Dialog::for_command(&app, "inspect.measure").unwrap();
|
||
d.pick(&app.session, wall.clone());
|
||
assert_eq!(d.items().len(), 2, "a rim and a wall are two measurement targets");
|
||
d.pick(&app.session, Sel::Face { body: b.name.clone(), index: faces[1] as usize, point: Vec3::new(-10.0, 0.0, 10.0) });
|
||
assert_eq!(d.items().len(), 1, "the other half toggles the same wall");
|
||
}
|
||
|
||
#[test]
|
||
fn circular_rim_hover_click_and_dialog_pick_include_both_halves() {
|
||
let mut app = cylinder_app();
|
||
let st = app.session.world_state();
|
||
let b = &st.bodies[0];
|
||
let m = b.mesh();
|
||
let top = m.edges.iter().position(|e| e.len() > 3 && e.iter().all(|p| (p.z - 20.0).abs() < 1e-6)).unwrap();
|
||
let rim = m.circular_rim(top);
|
||
assert_eq!(rim.len(), 2);
|
||
let line_count: usize = rim.iter().map(|&i| m.edges[i].len() - 1).sum();
|
||
for &i in &rim {
|
||
let mid = polyline_mid(&m.edges[i]);
|
||
app.viewport.hover = Some(Hit::Edge { body: b.name.clone(), index: i, mid });
|
||
assert_eq!(build_highlight(&app).lines.len(), 2 * line_count * crate::gpu::LINE_SIZE);
|
||
let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap();
|
||
select(&mut app, Some(item.clone()), false);
|
||
assert_eq!(app.session.selection.len(), 2);
|
||
// Selecting both pieces must not draw each highlight twice.
|
||
assert_eq!(build_highlight(&app).lines.len(), 2 * line_count * crate::gpu::LINE_SIZE);
|
||
// Ctrl/Shift clicking either half deselects the entire rim.
|
||
select(&mut app, Some(item.clone()), true);
|
||
assert!(app.session.selection.is_empty());
|
||
let mut d = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap();
|
||
if let crate::dialogs::Kind::Fillet { chain, .. } = &mut d.kind {
|
||
*chain = false;
|
||
}
|
||
d.pick(&app.session, item.clone());
|
||
assert_eq!(d.items().len(), 2);
|
||
d.pick(&app.session, item);
|
||
assert!(d.items().is_empty());
|
||
}
|
||
// Screen-space picking works from either side of the rim as the model turns.
|
||
for &i in &rim {
|
||
let mid = polyline_mid(&m.edges[i]);
|
||
app.cam = app.cam.looking_from(Vec3::new(mid.x, mid.y, 20.0));
|
||
app.cam.target = Vec3::new(0.0, 0.0, 10.0);
|
||
for perspective in [false, true] {
|
||
app.ui.perspective = perspective;
|
||
let proj = projection(&app, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)));
|
||
let hits = pick(&app, &proj, proj.to_screen(mid).unwrap());
|
||
let (_, sel) = candidate(&app, &hits).unwrap();
|
||
assert!(matches!(sel, Sel::Edge { .. }), "{hits:?}");
|
||
assert_eq!(grouped_selection(&app.session, &sel).len(), 2);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn curved_face_hover_selection_and_dialogs_cross_the_seam() {
|
||
let mut app = cylinder_app();
|
||
let st = app.session.world_state();
|
||
let b = &st.bodies[0];
|
||
let m = b.mesh();
|
||
let faces = m.edge_faces.iter().zip(&m.seams).find(|(_, seam)| **seam).unwrap().0;
|
||
assert_eq!(faces.len(), 2);
|
||
let triangle_count = m.tri_face.iter().filter(|f| faces.contains(f)).count();
|
||
for &f in faces {
|
||
let index = f as usize;
|
||
let t = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf == f).unwrap().0;
|
||
let [a, c, d] = m.tri(t).unwrap();
|
||
let point = (a + c + d) / 3.0;
|
||
app.viewport.hover = Some(Hit::Face { body: b.name.clone(), index, point });
|
||
assert_eq!(build_highlight(&app).tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE);
|
||
let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap();
|
||
select(&mut app, Some(item.clone()), false);
|
||
assert_eq!(app.session.selection.len(), 2);
|
||
assert_eq!(build_highlight(&app).tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE);
|
||
assert_eq!(app.session.selection.first(), Some(&item), "keep the original tool placement point first");
|
||
let mut dialog = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap();
|
||
assert_eq!(dialog.items().len(), 2);
|
||
dialog.pick(&app.session, item.clone());
|
||
assert!(dialog.items().is_empty());
|
||
dialog.pick(&app.session, item);
|
||
assert_eq!(dialog.items().len(), 2);
|
||
// A point elsewhere on the opposite half still toggles the whole surface off.
|
||
let other = faces.iter().find(|&&x| x != f).unwrap();
|
||
select(&mut app, Some(Sel::Face { body: b.name.clone(), index: *other as usize, point: m.positions[0] }), true);
|
||
assert!(app.session.selection.is_empty());
|
||
}
|
||
// Planar caps and box corners stay separate.
|
||
let cap = (0..b.body.face_count()).find(|&i| m.surface_patch(i).len() == 1).unwrap();
|
||
assert_eq!(m.surface_patch(cap), vec![cap]);
|
||
app.session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||
let st = app.session.world_state();
|
||
let box_body = st.bodies.last().unwrap();
|
||
for i in 0..box_body.body.face_count() {
|
||
assert_eq!(box_body.mesh().surface_patch(i), vec![i]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn immediate_wheel_zoom_applies_normalized_input_once() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.app.preferences.smooth_wheel_zoom = false;
|
||
h.app.preferences.trackpad_pan = false;
|
||
h.app.preferences.zoom_to_cursor = false;
|
||
h.call("ui.move", json!({"x":800,"y":500}));
|
||
let before = h.app.cam;
|
||
let line = h.ctx.options(|o| o.input_options.line_scroll_speed);
|
||
for (unit, amount, points) in
|
||
[(egui::MouseWheelUnit::Point, 120.0, 120.0), (egui::MouseWheelUnit::Line, 1.0, line), (egui::MouseWheelUnit::Page, 0.1, 100.0)]
|
||
{
|
||
let height = h.app.cam.half_height();
|
||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||
unit,
|
||
delta: vec2(0.0, amount),
|
||
modifiers: egui::Modifiers::NONE,
|
||
phase: egui::TouchPhase::Move,
|
||
});
|
||
h.frame();
|
||
let expected = height * (-f64::from(points) * 0.0015).exp();
|
||
assert!((h.app.cam.half_height() - expected).abs() < 1e-6);
|
||
let after = h.app.cam;
|
||
h.frames(30);
|
||
assert_eq!(h.app.cam, after, "idle frames must not replay wheel input");
|
||
}
|
||
assert_eq!(h.app.cam.target, before.target);
|
||
h.app.preferences.zoom_reverse = true;
|
||
let height = h.app.cam.half_height();
|
||
h.call("ui.scroll", json!({"x":800,"y":500,"delta":120}));
|
||
assert!((h.app.cam.half_height() - height * 0.18_f64.exp()).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn wheel_zoom_setting_changes_do_not_replay_smoothing() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.app.preferences.zoom_to_cursor = false;
|
||
h.app.preferences.trackpad_pan = false;
|
||
h.call("ui.move", json!({"x":800,"y":500}));
|
||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(0.0, 120.0),
|
||
modifiers: egui::Modifiers::NONE,
|
||
phase: egui::TouchPhase::Move,
|
||
});
|
||
h.frame();
|
||
assert!(h.ctx.input(|i| i.is_scrolling()));
|
||
h.app.preferences.smooth_wheel_zoom = false;
|
||
let stopped = h.app.cam;
|
||
h.frame();
|
||
assert_eq!(h.app.cam, stopped);
|
||
h.app.preferences.smooth_wheel_zoom = true;
|
||
h.frames(90);
|
||
assert_eq!(h.app.cam, stopped, "re-enabling must not replay the old smoothing tail");
|
||
assert!(!h.app.viewport.wheel_zoom_raw_until_idle);
|
||
h.app.preferences.smooth_wheel_zoom = false;
|
||
for _ in 0..3 {
|
||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(0.0, 120.0),
|
||
modifiers: egui::Modifiers::NONE,
|
||
phase: egui::TouchPhase::Move,
|
||
});
|
||
h.frame();
|
||
}
|
||
assert!((h.app.cam.half_height() - stopped.half_height() * (-0.54_f64).exp()).abs() < 1e-6);
|
||
let after = h.app.cam;
|
||
h.frames(90);
|
||
assert_eq!(h.app.cam, after);
|
||
}
|
||
|
||
#[test]
|
||
fn trackpad_two_finger_scroll_pans_without_zooming() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
assert!(h.app.preferences.trackpad_pan, "two-finger pan is the default");
|
||
h.app.preferences.zoom_to_cursor = false;
|
||
h.call("ui.move", json!({"x":800,"y":500}));
|
||
let gesture =
|
||
|phase| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(60.0, -40.0), modifiers: egui::Modifiers::NONE, phase };
|
||
// A bracketed trackpad gesture pans (measured macOS Start…End bracketing).
|
||
let before = h.app.cam;
|
||
h.app.synthetic.push(gesture(egui::TouchPhase::Start));
|
||
h.frame();
|
||
h.app.synthetic.push(gesture(egui::TouchPhase::Move));
|
||
h.frame();
|
||
assert_eq!(h.app.cam.half_height(), before.half_height(), "trackpad scroll must not zoom");
|
||
assert_ne!(h.app.cam.target, before.target, "trackpad scroll must pan");
|
||
h.app.synthetic.push(gesture(egui::TouchPhase::End));
|
||
h.frame();
|
||
let after = h.app.cam;
|
||
h.frames(30);
|
||
assert_eq!(h.app.cam, after, "pan must not drift once input stops");
|
||
// Bare moves are a mouse: vertical zooms…
|
||
h.app.preferences.smooth_wheel_zoom = false;
|
||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(0.0, 120.0),
|
||
modifiers: egui::Modifiers::NONE,
|
||
phase: egui::TouchPhase::Move,
|
||
});
|
||
h.frame();
|
||
assert!((h.app.cam.half_height() - after.half_height() * (-0.18_f64).exp()).abs() < 1e-6);
|
||
// …and horizontal pans without zooming (thumb wheel).
|
||
let zoomed = h.app.cam;
|
||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(40.0, 0.0),
|
||
modifiers: egui::Modifiers::NONE,
|
||
phase: egui::TouchPhase::Move,
|
||
});
|
||
h.frame();
|
||
assert_eq!(h.app.cam.half_height(), zoomed.half_height());
|
||
assert_ne!(h.app.cam.target, zoomed.target);
|
||
// Opting out restores the legacy wheel behaviour for all point input.
|
||
h.app.preferences.trackpad_pan = false;
|
||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(0.0, 120.0),
|
||
modifiers: egui::Modifiers::NONE,
|
||
phase: egui::TouchPhase::Move,
|
||
});
|
||
h.frame();
|
||
assert!((h.app.cam.half_height() - zoomed.half_height() * (-0.18_f64).exp()).abs() < 1e-6);
|
||
// Legacy mode also zooms bracketed input instead of panning it.
|
||
let legacy = h.app.cam.half_height();
|
||
let legacy_target = h.app.cam.target;
|
||
for phase in [egui::TouchPhase::Start, egui::TouchPhase::Move, egui::TouchPhase::End] {
|
||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(0.0, 120.0),
|
||
modifiers: egui::Modifiers::NONE,
|
||
phase,
|
||
});
|
||
h.frame();
|
||
}
|
||
assert!((h.app.cam.half_height() - legacy * (-0.18_f64).exp()).abs() < 1e-6);
|
||
assert_eq!(h.app.cam.target, legacy_target);
|
||
// The thumb wheel pans sideways even with panning off: it is a mouse.
|
||
let h0 = h.app.cam.half_height();
|
||
h.app.synthetic.push(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(40.0, 0.0),
|
||
modifiers: egui::Modifiers::NONE,
|
||
phase: egui::TouchPhase::Move,
|
||
});
|
||
h.frame();
|
||
assert_eq!(h.app.cam.half_height(), h0);
|
||
assert_ne!(h.app.cam.target, legacy_target);
|
||
}
|
||
|
||
#[test]
|
||
fn pinch_gesture_zooms_and_ignores_noise() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.app.preferences.smooth_wheel_zoom = false;
|
||
h.app.preferences.zoom_to_cursor = false;
|
||
h.call("ui.move", json!({"x":800,"y":500}));
|
||
let height = h.app.cam.half_height();
|
||
let target = h.app.cam.target;
|
||
h.app.synthetic.push(egui::Event::Zoom(1.2));
|
||
h.frame();
|
||
let zoomed = h.app.cam.half_height();
|
||
assert!(((zoomed - height / 1.2) / (height / 1.2)).abs() < 1e-6, "spread must zoom in");
|
||
assert_eq!(h.app.cam.target, target, "centre zoom keeps the target");
|
||
h.app.preferences.zoom_reverse = true;
|
||
let height = h.app.cam.half_height();
|
||
h.app.synthetic.push(egui::Event::Zoom(1.2));
|
||
h.frame();
|
||
let flipped = h.app.cam.half_height();
|
||
assert!(((flipped - height * 1.2) / (height * 1.2)).abs() < 1e-6, "reverse flips the pinch direction");
|
||
h.app.synthetic.push(egui::Event::Zoom(f32::NAN));
|
||
h.app.synthetic.push(egui::Event::Zoom(0.0));
|
||
h.app.synthetic.push(egui::Event::Zoom(1.0));
|
||
let stopped = h.app.cam;
|
||
h.frame();
|
||
assert_eq!(h.app.cam, stopped, "noise must leave the camera still");
|
||
}
|
||
|
||
#[test]
|
||
fn trackpad_gestures_over_dialogs_do_not_move_the_view() {
|
||
let ctx = egui::Context::default();
|
||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||
assert!(app.preferences.trackpad_pan);
|
||
app.viewport.no_pixels = true;
|
||
crate::help::run(&mut app, &ctx, "shortcuts");
|
||
let mut time = 0.0;
|
||
let pan =
|
||
|phase| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(40.0, -20.0), modifiers: egui::Modifiers::NONE, phase };
|
||
let mut frame = |app: &mut SolveApp, pointer: Option<Pos2>, events: Vec<egui::Event>| {
|
||
time += 1.0 / 60.0;
|
||
let mut input = events;
|
||
if let Some(p) = pointer {
|
||
input.insert(0, egui::Event::PointerMoved(p));
|
||
}
|
||
let mut output = ctx.run_ui(
|
||
egui::RawInput {
|
||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))),
|
||
time: Some(time),
|
||
events: input,
|
||
..Default::default()
|
||
},
|
||
|ui| {
|
||
egui::CentralPanel::default().show(ui, |ui| show(app, ui));
|
||
crate::prefs::show(app, ui.ctx());
|
||
},
|
||
);
|
||
output.textures_delta.clear();
|
||
};
|
||
for _ in 0..4 {
|
||
frame(&mut app, None, Vec::new());
|
||
}
|
||
let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
|
||
let over = window.center();
|
||
let height = app.cam.half_height();
|
||
let target = app.cam.target;
|
||
// A full gesture plus pinch over the dialog stays in the popup.
|
||
for events in
|
||
[vec![pan(egui::TouchPhase::Start)], vec![pan(egui::TouchPhase::Move), egui::Event::Zoom(1.2)], vec![pan(egui::TouchPhase::End)]]
|
||
{
|
||
frame(&mut app, Some(over), events);
|
||
assert_eq!(app.cam.half_height(), height, "trackpad input over a dialog must stay in the popup");
|
||
assert_eq!(app.cam.target, target);
|
||
}
|
||
let exposed = pos2(40.0, 700.0);
|
||
assert!(!window.contains(exposed));
|
||
frame(&mut app, Some(exposed), Vec::new());
|
||
frame(&mut app, Some(exposed), vec![pan(egui::TouchPhase::Start)]);
|
||
frame(&mut app, Some(exposed), vec![pan(egui::TouchPhase::Move)]);
|
||
assert_eq!(app.cam.half_height(), height);
|
||
assert_ne!(app.cam.target, target, "the exposed viewport still pans");
|
||
frame(&mut app, Some(exposed), vec![egui::Event::Zoom(1.2)]);
|
||
assert_ne!(app.cam.half_height(), height, "the exposed viewport still pinch-zooms");
|
||
frame(&mut app, Some(exposed), vec![pan(egui::TouchPhase::End)]);
|
||
}
|
||
|
||
#[test]
|
||
fn shortcuts_window_wheel_does_not_zoom_the_viewport() {
|
||
for smooth in [true, false] {
|
||
let ctx = egui::Context::default();
|
||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||
app.preferences.smooth_wheel_zoom = smooth;
|
||
app.preferences.trackpad_pan = false;
|
||
app.viewport.no_pixels = true;
|
||
crate::help::run(&mut app, &ctx, "shortcuts");
|
||
let mut time = 0.0;
|
||
let mut frame = |app: &mut SolveApp, pointer: Option<Pos2>, wheel: f32| {
|
||
time += 1.0 / 60.0;
|
||
let mut events = Vec::new();
|
||
if let Some(p) = pointer {
|
||
events.push(egui::Event::PointerMoved(p));
|
||
}
|
||
if wheel != 0.0 {
|
||
events.push(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(0.0, wheel),
|
||
modifiers: egui::Modifiers::NONE,
|
||
phase: egui::TouchPhase::Move,
|
||
});
|
||
}
|
||
let mut output = ctx.run_ui(
|
||
egui::RawInput {
|
||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))),
|
||
time: Some(time),
|
||
events,
|
||
..Default::default()
|
||
},
|
||
|ui| {
|
||
egui::CentralPanel::default().show(ui, |ui| show(app, ui));
|
||
crate::prefs::show(app, ui.ctx());
|
||
},
|
||
);
|
||
output.textures_delta.clear();
|
||
};
|
||
// Let egui establish the floating window's size and hit-test widgets.
|
||
for _ in 0..4 {
|
||
frame(&mut app, None, 0.0);
|
||
}
|
||
let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
|
||
let list = window.center() + vec2(140.0, 0.0);
|
||
let height = app.cam.half_height();
|
||
let target = app.cam.target;
|
||
frame(&mut app, Some(list), 0.0);
|
||
for n in 0..12 {
|
||
frame(&mut app, Some(list), if n == 0 { -120.0 } else { 0.0 });
|
||
assert_eq!(app.cam.half_height(), height, "wheel input over shortcuts must stay in the popup");
|
||
assert_eq!(app.cam.target, target);
|
||
}
|
||
// The window's non-scrolling sidebar must also shield the model.
|
||
frame(&mut app, Some(window.left_center() + vec2(30.0, 0.0)), -120.0);
|
||
assert_eq!(app.cam.half_height(), height);
|
||
// An exposed part of the viewport still accepts wheel navigation with the popup open.
|
||
let exposed = pos2(40.0, 700.0);
|
||
assert!(!window.contains(exposed));
|
||
frame(&mut app, Some(exposed), 0.0);
|
||
frame(&mut app, Some(exposed), -120.0);
|
||
assert_ne!(app.cam.half_height(), height);
|
||
// Closing the popup returns its former area to the viewport.
|
||
app.prefs_window.open = false;
|
||
for _ in 0..12 {
|
||
frame(&mut app, Some(list), 0.0);
|
||
}
|
||
let height = app.cam.half_height();
|
||
frame(&mut app, Some(list), -120.0);
|
||
assert_ne!(app.cam.half_height(), height);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn wheel_by_a_dialog_list_or_panel_edge_leaves_the_model_alone() {
|
||
// #678: scrolling the Physical Material list, a drop-down that hangs over the model,
|
||
// zoomed the model too. egui's hit test counts the model as under the pointer within a
|
||
// few points of a window's, a list's or the viewport's own edge, so near those edges
|
||
// the same wheel turn reached both.
|
||
use crate::scenario::{Harness, handle_at};
|
||
// One wheel turn at `p`, then frames until egui's smoothing is spent: did the camera move?
|
||
let wheel = |h: &mut Harness, p: Pos2, delta: f32| {
|
||
let before = h.app.cam;
|
||
h.call("ui.scroll", json!({"x": p.x, "y": p.y, "delta": delta}));
|
||
h.frames(20);
|
||
h.app.cam != before
|
||
};
|
||
// A trackpad pinch, then a two-finger scroll (which pans the model): did the camera move?
|
||
let gestures = |h: &mut Harness, p: Pos2| {
|
||
let before = h.app.cam;
|
||
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||
h.app.synthetic.push(egui::Event::Zoom(1.2));
|
||
for phase in [egui::TouchPhase::Start, egui::TouchPhase::Move, egui::TouchPhase::End] {
|
||
let unit = egui::MouseWheelUnit::Point;
|
||
h.app.synthetic.push(egui::Event::MouseWheel { unit, delta: vec2(30.0, -20.0), modifiers: egui::Modifiers::NONE, phase });
|
||
}
|
||
h.settle();
|
||
h.frames(20);
|
||
h.app.cam != before
|
||
};
|
||
for smooth in [true, false] {
|
||
let mut h = Harness::new();
|
||
h.app.preferences.smooth_wheel_zoom = smooth;
|
||
h.app.preferences.zoom_to_cursor = false;
|
||
h.call("engine.execute", json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}));
|
||
h.call("ui.start", json!({"command": "material.assign"}));
|
||
let field = handle_at("dialog:Material").expect("the dialog's material field");
|
||
let field = json!({"x": field.x, "y": field.y});
|
||
h.call("ui.move", field.clone());
|
||
h.call("ui.click", field.clone());
|
||
let item = "dialog:Material:Steel";
|
||
let first = handle_at(item).expect("the open material list");
|
||
let list = h
|
||
.ctx
|
||
.memory(|m| {
|
||
let popups = m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Foreground);
|
||
popups.filter_map(|l| m.area_rect(l.id)).find(|r| r.contains(first))
|
||
})
|
||
.expect("the list's popup");
|
||
let dialog = h.app.viewport.dialog_rect.expect("the dialog");
|
||
let vp = h.app.viewport.rect.expect("the viewport");
|
||
assert!(!dialog.contains(first) && vp.contains(first), "the list must hang over the model: {first:?} {dialog:?}");
|
||
|
||
// The wheel scrolls the list and only the list, in its middle and by its edges.
|
||
assert!(!wheel(&mut h, pos2(list.center().x, first.y), -60.0), "smooth {smooth}: the wheel over the list zoomed the model");
|
||
let moved = handle_at(item).expect("the list stays open");
|
||
assert!(moved.y < first.y - 1.0, "the wheel must scroll the list: {first:?} -> {moved:?}");
|
||
for p in [pos2(list.left() + 2.0, list.center().y), pos2(list.right() - 2.0, list.center().y), pos2(list.center().x, list.bottom() - 2.0)]
|
||
{
|
||
assert!(!wheel(&mut h, p, 60.0), "smooth {smooth}: the wheel at {p:?} by the list's edge ({list:?}) zoomed the model");
|
||
assert!(!gestures(&mut h, p), "a pinch or two-finger scroll at {p:?} by the list's edge ({list:?}) moved the model");
|
||
assert!(h.app.viewport.mouse.is_none(), "the model must not see the pointer at {p:?} over the list");
|
||
}
|
||
|
||
// With the list closed: by the dialog's edges, and over the browser and the timeline
|
||
// next to the model.
|
||
h.call("ui.move", field.clone());
|
||
h.call("ui.click", field);
|
||
assert!(handle_at(item).is_none(), "the list must close");
|
||
let (mid, x) = (vp.center().y, vp.left() + vp.width() * 0.25);
|
||
for p in [
|
||
pos2(dialog.left() + 2.0, dialog.center().y),
|
||
pos2(dialog.center().x, dialog.bottom() - 2.0),
|
||
pos2(vp.left() - 2.0, mid),
|
||
pos2(x, vp.bottom() + 2.0),
|
||
] {
|
||
assert!(!wheel(&mut h, p, 60.0), "smooth {smooth}: the wheel at {p:?} off the model ({vp:?}, dialog {dialog:?}) zoomed it");
|
||
assert!(h.app.viewport.mouse.is_none(), "the model must not see the pointer at {p:?}");
|
||
}
|
||
// The model itself still zooms right next to them.
|
||
for p in [pos2(dialog.left() - 2.0, dialog.center().y), pos2(vp.left() + 2.0, mid), pos2(x, vp.bottom() - 2.0)] {
|
||
assert!(wheel(&mut h, p, 60.0), "smooth {smooth}: the wheel at {p:?} on the model ({vp:?}) must zoom it");
|
||
assert_eq!(h.app.viewport.mouse, Some(p));
|
||
assert!(gestures(&mut h, p), "a pinch and a two-finger scroll at {p:?} on the model must move it");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn floating_dialog_blocks_viewport_hover_and_clicks() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.call("engine.execute", json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}));
|
||
h.call("ui.view", json!({"view": "fit"}));
|
||
h.app.ui.pick_bodies = true;
|
||
let p = h.app.viewport.rect.unwrap().center();
|
||
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||
assert!(h.app.viewport.hover.is_some(), "the test point must hover over the model");
|
||
h.call("ui.click", json!({"x": p.x, "y": p.y}));
|
||
assert!(!h.app.session.selection.is_empty(), "the uncovered model must be selectable");
|
||
h.app.session.selection.clear();
|
||
|
||
h.call("ui.prefs", json!({"open": "Navigation"}));
|
||
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
|
||
assert!(window.contains(p), "the dialog must cover the model at the test point");
|
||
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||
assert!(h.app.viewport.hover.is_none(), "the dialog must block model highlights");
|
||
assert!(h.app.viewport.mouse.is_none());
|
||
h.call("ui.click", json!({"x": p.x, "y": p.y}));
|
||
assert!(h.app.session.selection.is_empty(), "clicks in the dialog must not select the model");
|
||
|
||
h.call("ui.prefs", json!({"button": "cancel"}));
|
||
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||
assert!(h.app.viewport.hover.is_some(), "closing the dialog must restore model highlights");
|
||
h.call("ui.click", json!({"x": p.x, "y": p.y}));
|
||
assert!(!h.app.session.selection.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn floating_dialog_blocks_axis_view_highlights_and_clicks() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.call("ui.resize", json!({"width": 800, "height": 700}));
|
||
let p = crate::scenario::handle_at("axis_view:z").unwrap();
|
||
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||
assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(1.0));
|
||
h.call("ui.prefs", json!({"open": "Navigation"}));
|
||
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
|
||
assert!(window.contains(p), "the floating dialog must cover the axis target");
|
||
let camera = serde_json::to_value(h.app.cam).unwrap();
|
||
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||
assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(0.0));
|
||
h.call("ui.click", json!({"x": p.x, "y": p.y}));
|
||
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog clicks must not turn the axis view");
|
||
h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 20.0, "y1": p.y, "steps": 8}));
|
||
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog drags must not orbit the axis gizmo");
|
||
assert!(h.app.viewport.axis_drag.is_none());
|
||
h.call("ui.prefs", json!({"button": "cancel"}));
|
||
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||
assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(1.0));
|
||
h.call("ui.click", json!({"x": p.x, "y": p.y}));
|
||
assert_ne!(serde_json::to_value(h.app.cam).unwrap(), camera);
|
||
}
|
||
|
||
#[test]
|
||
fn view_axis_drag_owns_navigation_and_keeps_the_view_centre() {
|
||
for nav in [None, Some(NavMode::Orbit), Some(NavMode::Pan)] {
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.call("ui.view", json!({"view": "front"}));
|
||
h.app.viewport.nav = nav;
|
||
let before = h.app.cam;
|
||
let p = crate::scenario::handle_at("axis_view:-y").unwrap();
|
||
h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 48.0, "y1": p.y, "steps": 8}));
|
||
assert!((h.app.cam.yaw - 48.0 * 0.008).abs() < 1e-6, "{nav:?}: axis drag must orbit exactly once");
|
||
assert_eq!(h.app.cam.target, before.target, "{nav:?}: axis drag must not pan");
|
||
assert_eq!(h.app.cam.distance, before.distance);
|
||
assert_eq!(h.app.cam.pitch, before.pitch);
|
||
assert!(h.app.viewport.axis_drag.is_none(), "release clears the axis gesture");
|
||
assert!(h.app.viewport.boxsel.is_none());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn section_outlines_follow_the_cut() {
|
||
let mut s = solidcraft_engine::Session::default();
|
||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||
let st = s.model.state();
|
||
let m = st.bodies[0].mesh();
|
||
let (o, n) = (Vec3::new(0.0, 15.0, 0.0), Vec3::new(0.0, -1.0, 0.0));
|
||
let segs = solidcraft_engine::view::section_segments(&m, o, n);
|
||
assert!(segs.len() >= 4, "{segs:?}");
|
||
// On the plane (a hair to the kept side) and around the 40 x 20 cut.
|
||
let mut len = 0.0;
|
||
for (a, b) in &segs {
|
||
assert!((a.y - 15.0).abs() < 0.01 && (b.y - 15.0).abs() < 0.01);
|
||
len += a.dist(*b);
|
||
}
|
||
assert!((len - 120.0).abs() < 0.5, "outline length {len}");
|
||
// A plane that misses the body cuts nothing.
|
||
assert!(solidcraft_engine::view::section_segments(&m, Vec3::new(0.0, 99.0, 0.0), n).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn selection_shows_where_the_model_hides_it() {
|
||
let mut s = solidcraft_engine::Session::default();
|
||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||
let mut app = SolveApp::new(s, crate::Services::default());
|
||
assert!(build_highlight(&app).hidden.is_empty());
|
||
// The back face: its outline is drawn (dimmed) where the box hides it.
|
||
app.session.selection = vec![Sel::Face { body: "Body1".into(), index: 3, point: Vec3::new(20.0, 30.0, 10.0) }];
|
||
assert!(!build_highlight(&app).hidden.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn axis_view_top_bottom_choose_nearest_square_heading() {
|
||
for (from, to) in [
|
||
(0.0, 0.0),
|
||
(30.0, 0.0),
|
||
(-80.0, -90.0),
|
||
(100.0, 90.0),
|
||
(170.0, 180.0),
|
||
(400.0, 360.0),
|
||
(-400.0, -360.0),
|
||
(-45.0, 0.0),
|
||
(45.0, 0.0),
|
||
(135.0, 90.0),
|
||
(-135.0, -90.0),
|
||
(44.99, 0.0),
|
||
(45.01, 90.0),
|
||
(-44.99, 0.0),
|
||
(-45.01, -90.0),
|
||
] {
|
||
let got = nearest_quarter_turn(f64::to_radians(from)).to_degrees();
|
||
assert!((got - to).abs() < 1e-9, "{from}° → {got}°, expected {to}°");
|
||
}
|
||
assert_eq!(nearest_quarter_turn(f64::NAN), 0.0);
|
||
assert_eq!(nearest_quarter_turn(f64::INFINITY), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn shift_orbit_accumulates_and_returns_to_free_rotation() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.call("ui.view", json!({"view":"front"}));
|
||
let rect = h.app.viewport.rect.unwrap();
|
||
let before = h.app.cam;
|
||
for _ in 0..100 {
|
||
orbit_drag(&mut h.app, rect, 2.0, 0.0, None, true, false);
|
||
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
|
||
}
|
||
for _ in 0..120 {
|
||
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
|
||
}
|
||
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6);
|
||
assert_eq!(h.app.cam.target, before.target);
|
||
assert_eq!(h.app.cam.distance, before.distance);
|
||
orbit_drag(&mut h.app, rect, 0.0, 0.0, None, false, false);
|
||
h.settle();
|
||
assert!((h.app.cam.yaw - 1.6).abs() < 1e-6);
|
||
assert!(h.app.viewport.orbit_free.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn axis_snap_selects_all_directions_in_both_up_frames() {
|
||
assert_eq!(snap_directions().count(), 26);
|
||
for up in [solidcraft_engine::render::UpAxis::Z, solidcraft_engine::render::UpAxis::Y] {
|
||
for d in snap_directions() {
|
||
let mut cam = Camera { up, ..Camera::default() };
|
||
cam = cam.looking_from(cam.from_frame(d + Vec3::new(0.05, 0.03, 0.02)));
|
||
let to = snapped_axis_camera(cam);
|
||
assert!(to.back().dist(cam.from_frame(d)) < 1e-6);
|
||
assert_eq!(to.target, cam.target);
|
||
assert_eq!(to.distance, cam.distance);
|
||
assert_eq!(to.fov, cam.fov);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn camera_resets_cancel_pending_axis_motion() {
|
||
for reset in 0..3 {
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||
let rect = h.app.viewport.rect.unwrap();
|
||
orbit_drag(&mut h.app, rect, 120.0, 40.0, None, true, false);
|
||
assert!(h.app.viewport.orbit_snap.is_some());
|
||
match reset {
|
||
0 => h.app.fit_view(),
|
||
1 => h.app.set_up_axis(solidcraft_engine::render::UpAxis::Y),
|
||
_ => crate::documents::new_design(&mut h.app),
|
||
}
|
||
let before = h.app.cam;
|
||
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
|
||
assert_eq!(h.app.cam.yaw, before.yaw);
|
||
assert_eq!(h.app.cam.pitch, before.pitch);
|
||
assert_eq!(h.app.cam.target, before.target);
|
||
assert!(h.app.viewport.orbit_snap.is_none());
|
||
assert!(h.app.viewport.orbit_free.is_none());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn axis_snap_spring_eases_in_and_settles_at_each_frame_rate() {
|
||
for fps in [30, 60, 120] {
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.call("ui.view", json!({"view":"front"}));
|
||
let rect = h.app.viewport.rect.unwrap();
|
||
orbit_drag(&mut h.app, rect, 120.0, 0.0, None, true, false);
|
||
assert_eq!(h.app.cam.yaw, 0.0, "choosing a diagonal must not jump the camera");
|
||
step_orbit_snap(&mut h.app, rect, 1.0 / f64::from(fps));
|
||
assert!(h.app.cam.yaw > 0.0 && h.app.cam.yaw < std::f64::consts::FRAC_PI_4 * 0.25);
|
||
for _ in 0..fps * 2 {
|
||
step_orbit_snap(&mut h.app, rect, 1.0 / f64::from(fps));
|
||
assert!(h.app.cam.yaw <= std::f64::consts::FRAC_PI_4 + 0.03, "elastic finish stays small");
|
||
}
|
||
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_4).abs() < 1e-6);
|
||
assert!(h.app.viewport.orbit_snap.is_none());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn shift_orbit_keeps_cursor_pivot_and_default_middle_pan() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.app.preferences.orbit_cursor = true;
|
||
h.app.run("solid.box", json!({"length":40,"width":40,"height":40})).unwrap();
|
||
h.app.fit_view();
|
||
let rect = h.app.viewport.rect.unwrap();
|
||
let at = rect.center() + vec2(50.0, -20.0);
|
||
orbit_drag(&mut h.app, rect, 0.0, 0.0, Some(at), false, true);
|
||
let pivot = h.app.viewport.orbit_pivot.unwrap();
|
||
let before = projection(&h.app, rect).to_screen(pivot).unwrap();
|
||
for _ in 0..20 {
|
||
orbit_drag(&mut h.app, rect, 4.0, 2.0, Some(at), true, true);
|
||
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
|
||
let after = projection(&h.app, rect).to_screen(pivot).unwrap();
|
||
assert!(after.distance(before) < 2.0);
|
||
}
|
||
h.app.viewport.orbit_free = None;
|
||
h.app.viewport.orbit_pivot = None;
|
||
h.call("ui.view", json!({"view":"front"}));
|
||
let before = h.app.cam;
|
||
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":1020,"y1":500,"button":"middle","shift":true,"steps":40}));
|
||
assert!((h.app.cam.yaw - 1.76).abs() < 1e-6, "before={before:?}, after={:?}", h.app.cam);
|
||
assert!(h.app.viewport.orbit_snap.is_none());
|
||
h.call("ui.view", json!({"view":"front"}));
|
||
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":850,"y1":520,"button":"middle"}));
|
||
assert_eq!(h.app.cam.yaw, before.yaw);
|
||
assert_eq!(h.app.cam.pitch, before.pitch);
|
||
assert_ne!(h.app.cam.target, before.target);
|
||
}
|
||
|
||
#[test]
|
||
fn preset_shift_middle_orbit_is_free_but_shift_right_orbit_snaps() {
|
||
for preset in ["fusion", "inventor"] {
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.app.preferences.nav = preset.into();
|
||
h.call("ui.view", json!({"view":"front"}));
|
||
let before = h.app.cam;
|
||
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":850,"y1":520,"button":"middle","shift":true,"steps":20}));
|
||
let mut expected = before;
|
||
expected.orbit(50.0, 20.0);
|
||
assert!((h.app.cam.yaw - expected.yaw).abs() < 1e-6, "{preset}: {:?}", h.app.cam);
|
||
assert!((h.app.cam.pitch - expected.pitch).abs() < 1e-6, "{preset}: {:?}", h.app.cam);
|
||
assert_eq!(h.app.cam.target, before.target);
|
||
assert_eq!(h.app.cam.distance, before.distance);
|
||
assert!(h.app.viewport.orbit_snap.is_none());
|
||
h.call("ui.view", json!({"view":"front"}));
|
||
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":1020,"y1":500,"button":"right","shift":true,"steps":40}));
|
||
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "{preset}: {:?}", h.app.cam);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn navigation_cube_repeated_face_click_keeps_view_and_drag_snaps() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.call("ui.prefs", json!({"navigator_style":"cube"}));
|
||
h.run(&json!([
|
||
{"call":"ui.view","params":{"view":"home"}},
|
||
{"click":{"handle":"axis_view:z"}},
|
||
{"expect":{"camera":{"pitch_deg":90,"yaw_deg":0}}},
|
||
{"click":{"handle":"axis_view:z"}},
|
||
{"expect":{"camera":{"pitch_deg":90,"yaw_deg":0}}},
|
||
{"call":"ui.view","params":{"view":"front"}}
|
||
]))
|
||
.unwrap();
|
||
let c = axis_center(&h.app, h.app.viewport.rect.unwrap());
|
||
h.call("ui.drag", json!({"x0":c.x,"y0":c.y,"x1":c.x+220.0,"y1":c.y,"shift":true,"steps":40}));
|
||
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6);
|
||
assert!(h.app.viewport.axis_drag.is_none());
|
||
assert!(h.app.viewport.orbit_free.is_none());
|
||
assert!(h.app.viewport.boxsel.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn construction_axes_span_the_model_and_points_show() {
|
||
let mut s = solidcraft_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.session.visibility.hidden_origin.push(name);
|
||
assert!(construction_axis_segments(&app).is_empty());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "view_home_tests.rs"]
|
||
mod view_home_tests;
|