* GPU tessellation: a pipeline the device cannot build falls back to CPU meshing instead of crashing (#1098) Build the tessellation compute pipeline inside error scopes; TessGpu::new returns an error, and the viewport logs it and meshes bodies on the CPU. A Windows CI job builds the pipeline with D3D12's FXC on the WARP adapter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tessellation shader: basis functions in one-dimensional local arrays, so D3D12's FXC compiles it (#1098) FXC has to unroll loops that write an array inside a struct (Basis.n0/n1) or a 2-D array (ndu) by a counter, and cannot with run-time trip counts (X3511). A2.2 basis functions in a local 1-D array returned whole; derivatives from the degree p-1 functions; the pole step offsets computed rather than indexed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
4800 lines
224 KiB
Rust
4800 lines
224 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)
|
||
}
|
||
}
|
||
|
||
/// With a component active, bodies outside it are drawn faded.
|
||
fn body_faded(app: &SolveApp, b: &solidcraft_engine::doc::ModelBody, active_bodies: &Option<Vec<String>>) -> bool {
|
||
let s = &app.session;
|
||
if let Some(names) = active_bodies {
|
||
!names.contains(&b.name)
|
||
} else {
|
||
s.active_component != 0 && !s.doc.component_within(b.component, s.active_component)
|
||
}
|
||
}
|
||
|
||
/// The display plan the model scene draws `b` from on the GPU, or `None` when its faces are built
|
||
/// here from the reference mesh. Opaque shaded bodies (`opaque`: not faded, and no see-through
|
||
/// body or face) are drawn from their plans where the device evaluates them; faded and
|
||
/// see-through ones (sorted and blended per triangle) are built here.
|
||
fn drawn_plan(
|
||
app: &SolveApp,
|
||
b: &solidcraft_engine::doc::ModelBody,
|
||
opaque: bool,
|
||
) -> Option<std::sync::Arc<solidcraft_engine::geom::tess::TessPlan>> {
|
||
let shaded = app.ui.visual_style != 2;
|
||
(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)
|
||
}
|
||
|
||
/// The mesh whose triangles the model scene shows for `b`'s faces: the plan's (evaluated on the
|
||
/// CPU, the same triangles the GPU draws) or the reference mesh. A face highlighted over the model
|
||
/// must use the same triangles: another triangulation of the same face fights it at equal depth
|
||
/// and shimmers.
|
||
fn shown_mesh(
|
||
app: &SolveApp,
|
||
b: &solidcraft_engine::doc::ModelBody,
|
||
active_bodies: &Option<Vec<String>>,
|
||
) -> std::sync::Arc<solidcraft_engine::geom::Mesh> {
|
||
let doc = &app.session.doc;
|
||
let opaque =
|
||
|| !body_faded(app, b, active_bodies) && doc.model_body_opacity(b) >= 0.999 && doc.face_colors(b).iter().all(|(_, l)| l.opacity >= 0.999);
|
||
(app.viewport.gpu.is_some_and(|g| g.tess) && opaque())
|
||
.then(|| drawn_plan(app, b, true))
|
||
.flatten()
|
||
.and_then(|_| b.plan_mesh())
|
||
.unwrap_or_else(|| b.mesh())
|
||
}
|
||
|
||
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;
|
||
}
|
||
let faded = body_faded(app, b, &active_bodies);
|
||
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() };
|
||
let opaque = !faded && opacity >= 0.999 && face_looks.values().all(|l| l.opacity >= 0.999);
|
||
let planned = drawn_plan(app, b, opaque);
|
||
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);
|
||
// Which mesh lights a face follows how the model draws its body (see `shown_mesh`).
|
||
app.session.active_component.hash(&mut h);
|
||
app.session.active_occurrence_path.hash(&mut h);
|
||
app.ui.visual_style.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 s = &app.session;
|
||
let active_bodies = s.active_occurrence_path.as_ref().and_then(|p| s.occurrence_bodies(p, true).ok());
|
||
// Faces lit over the model use the triangles the model shows them with.
|
||
let shown = |b: &solidcraft_engine::doc::ModelBody| shown_mesh(app, b, &active_bodies);
|
||
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, &shown(b), *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 = shown(b);
|
||
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();
|
||
let lit = shown(b);
|
||
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, &lit, 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 = shown(b);
|
||
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);
|
||
let lit = shown(b);
|
||
for &f in &patch {
|
||
face_tris(&mut sc, &lit, 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, shader) = (scene.planned.len(), tess.then_some(solidcraft_gpu::SHADER));
|
||
(planned, scene.tris.len(), crate::gpu::render_offscreen(scene, None, proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, shader))
|
||
};
|
||
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");
|
||
}
|
||
|
||
/// A device that cannot build the tessellation pipeline (D3D12's FXC rejected the shader
|
||
/// at startup and the app panicked) still shows the model: the failure is caught, logged,
|
||
/// and bodies are built on the CPU and drawn. A shader the device rejects stands in for FXC.
|
||
/// Skipped without a GPU adapter.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn bodies_are_built_on_the_cpu_when_the_tessellation_pipeline_fails() {
|
||
const REJECTED: &str = "
|
||
@group(0) @binding(1) var<storage, read_write> surfaces: array<u32>;
|
||
@compute @workgroup_size(64)
|
||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) { surfaces[gid.x] = 1u; }
|
||
";
|
||
let Some(tess) = crate::gpu::offscreen_tessellates(REJECTED) else {
|
||
eprintln!("skipping: no GPU adapter");
|
||
return;
|
||
};
|
||
assert!(!tess, "the rejected shader built a pipeline");
|
||
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();
|
||
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess });
|
||
const SIZE: u32 = 256;
|
||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32));
|
||
let scene = build_scene(&app);
|
||
assert!(scene.planned.is_empty(), "no body waits for the GPU evaluation");
|
||
assert!(!scene.tris.is_empty(), "the bodies are meshed on the CPU");
|
||
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 Some(img) = crate::gpu::render_offscreen(scene, None, proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, Some(REJECTED)) else {
|
||
return;
|
||
};
|
||
let n = (SIZE * SIZE) as usize;
|
||
let drawn = img.chunks(4).filter(|p| p[..3] != [255, 255, 255]).count();
|
||
assert!(drawn > n / 20, "the model is drawn: {drawn} pixels");
|
||
}
|
||
|
||
/// A hovered face lights solidly over a body drawn from its display plan on the GPU: the
|
||
/// highlight uses the plan's triangles, not the reference mesh's other triangulation of the
|
||
/// same face, which fought the model at equal depth and shimmered in wedges. Every face of a
|
||
/// box with a pocket and a round boss, hovered, changes the same pixels as on the CPU path (where model and
|
||
/// highlight share the reference mesh). Skipped without a GPU adapter.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn a_hovered_face_lights_whole_over_a_body_drawn_from_its_plan() {
|
||
let mut s = solidcraft_engine::Session::default();
|
||
s.execute("solid.box", &json!({"length": 40, "width": 40, "height": 40, "corner": [0, 0, 0], "operation": "new"})).unwrap();
|
||
s.execute("solid.box", &json!({"length": 16, "width": 16, "height": 20, "corner": [12, 12, 20], "operation": "cut"})).unwrap();
|
||
s.execute("solid.cylinder", &json!({"base": [33, 33, 40], "diameter": 10, "height": 10, "operation": "join"})).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 body = app.session.world_state().bodies[0].clone();
|
||
let faces = body.body.face_count();
|
||
let mut render = |tess: bool, hover: Option<usize>| {
|
||
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess });
|
||
app.viewport.hover = hover.map(|index| Hit::Face { body: body.name.clone(), index, point: Vec3::ZERO });
|
||
let (scene, highlight) = (build_scene(&app), build_highlight(&app));
|
||
assert_eq!(!scene.planned.is_empty(), tess, "the box is drawn from its plan exactly on the GPU path");
|
||
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 shader = tess.then_some(solidcraft_gpu::SHADER);
|
||
crate::gpu::render_offscreen(scene, Some(highlight), proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, shader)
|
||
};
|
||
let lit = |plain: &[u8], hovered: &[u8]| -> Vec<bool> { plain.chunks(4).zip(hovered.chunks(4)).map(|(a, b)| a != b).collect() };
|
||
let (Some(gpu_plain), Some(cpu_plain)) = (render(true, None), render(false, None)) else {
|
||
eprintln!("skipping: no GPU adapter");
|
||
return;
|
||
};
|
||
let mut checked = 0;
|
||
for f in 0..faces {
|
||
let (Some(gpu), Some(cpu)) = (render(true, Some(f)), render(false, Some(f))) else { return };
|
||
let (on_gpu, on_cpu) = (lit(&gpu_plain, &gpu), lit(&cpu_plain, &cpu));
|
||
let area = on_cpu.iter().filter(|x| **x).count();
|
||
if area < 400 {
|
||
continue; // facing away or hidden
|
||
}
|
||
// Inside the face, two pixels in from its outline: a curved face's outline follows
|
||
// its own facets, which the plan and the reference mesh cut differently.
|
||
let n = SIZE as i32;
|
||
let inside = |i: usize| {
|
||
let (x, y) = (i as i32 % n, i as i32 / n);
|
||
(-2..=2).all(|dy| (-2..=2).all(|dx| on_cpu.get(((y + dy).clamp(0, n - 1) * n + (x + dx).clamp(0, n - 1)) as usize) == Some(&true)))
|
||
};
|
||
let missed = (0..on_cpu.len()).filter(|&i| inside(i) && !on_gpu[i]).count();
|
||
assert!(missed * 200 < area, "face {f}: {missed} of its {area} hovered pixels do not light (the highlight fights the model)");
|
||
checked += 1;
|
||
}
|
||
assert!(checked >= 3, "{checked} faces seen lit");
|
||
}
|
||
|
||
#[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;
|