Fixes #15 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2127 lines
90 KiB
Rust
2127 lines
90 KiB
Rust
//! The 3D viewport: navigation, GPU (or CPU fallback) rendering, picking, overlays, the view
|
||
//! cube and the navigation bar.
|
||
|
||
use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2};
|
||
use serde_json::json;
|
||
use solvecraft_engine::Sel;
|
||
use solvecraft_engine::geom::{Vec2, Vec3};
|
||
use solvecraft_engine::render::{Camera, Mat4, Rgb, StandardView};
|
||
use solvecraft_engine::sketch::{ConstraintKind, CurveKind};
|
||
use solvecraft_engine::view::{colors, grid_step, sketch_consumed, sketch_lines};
|
||
use std::hash::{Hash, Hasher};
|
||
|
||
use crate::gpu::{GpuScene, GpuTarget, SceneSlot, ViewportCallback};
|
||
use crate::selection::{BoxSel, origin_quads, origin_size, ray_quad};
|
||
use crate::theme::Tokens;
|
||
use crate::{SolveApp, icons};
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum NavMode {
|
||
Orbit,
|
||
Pan,
|
||
Zoom,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct ViewportState {
|
||
/// Per-body bounding boxes for fast picking, keyed by the model they were taken from.
|
||
pick_bounds: std::sync::Mutex<Option<(usize, std::sync::Arc<Vec<(String, solvecraft_engine::geom::Aabb3)>>)>>,
|
||
/// 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 gpu: Option<GpuTarget>,
|
||
slot: SceneSlot,
|
||
key: u64,
|
||
hl_slot: SceneSlot,
|
||
hl_key: u64,
|
||
cpu: Option<(u64, egui::TextureHandle)>,
|
||
pub nav: Option<NavMode>,
|
||
/// What a click would pick now (after the active command's filter).
|
||
pub hover: Option<Hit>,
|
||
pub mouse: Option<Pos2>,
|
||
/// Box selection being dragged.
|
||
pub boxsel: Option<BoxSel>,
|
||
/// Timeline item under the cursor: its bodies 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,
|
||
pub build_ms: f64,
|
||
/// The right-click menu, open at this screen position.
|
||
pub context_menu: Option<Pos2>,
|
||
/// 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>)>,
|
||
}
|
||
|
||
/// 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,
|
||
},
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
}
|
||
|
||
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| {
|
||
app.session.active_sketch == Some(s.feature)
|
||
|| (!app.ui.hidden_sketches.contains(&s.feature)
|
||
&& (app.ui.shown_sketches.contains(&s.feature) || (app.ui.show_sketches && !sketch_consumed(&app.session, s.feature))))
|
||
})
|
||
.map(|s| s.feature)
|
||
.collect()
|
||
}
|
||
|
||
fn scene_key(app: &SolveApp) -> u64 {
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
app.session.revision.hash(&mut h);
|
||
app.ui.show_grid.hash(&mut h);
|
||
app.ui.visual_style.hash(&mut h);
|
||
app.ui.ground_shadow.hash(&mut h);
|
||
(app.cam.pitch > 0.0).hash(&mut h);
|
||
app.ui.show_sketches.hash(&mut h);
|
||
app.ui.hidden_bodies.hash(&mut h);
|
||
app.ui.hidden_sketches.hash(&mut h);
|
||
app.ui.shown_sketches.hash(&mut h);
|
||
app.ui.hidden_profiles.hash(&mut h);
|
||
app.session.active_component.hash(&mut h);
|
||
app.preview.replaced.hash(&mut h);
|
||
crate::theme::is_dark().hash(&mut h);
|
||
app.session.active_sketch.hash(&mut h);
|
||
let (minor, _) = grid_step(app.cam.half_height());
|
||
minor.to_bits().hash(&mut h);
|
||
((app.cam.target.x / (minor * 5.0)).round() as i64).hash(&mut h);
|
||
((app.cam.target.y / (minor * 5.0)).round() as i64).hash(&mut h);
|
||
h.finish()
|
||
}
|
||
|
||
/// A sketch curve colour from the engine, in the current theme.
|
||
fn sketch_color(t: &Tokens, c: Rgb) -> [u8; 4] {
|
||
if c == colors::SKETCH {
|
||
c4(t.sketch)
|
||
} else if c == colors::SKETCH_DONE || c == colors::SKETCH_FIXED {
|
||
c4(t.sketch_fixed)
|
||
} else if c == colors::SKETCH_LOCKED {
|
||
c4(t.sketch_locked)
|
||
} else {
|
||
rgba(c)
|
||
}
|
||
}
|
||
|
||
fn build_scene(app: &SolveApp) -> GpuScene {
|
||
let tk = Tokens::get();
|
||
let mut sc = GpuScene::default();
|
||
let s = &app.session;
|
||
// Bodies where their occurrences place them.
|
||
let st = s.world_state();
|
||
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;
|
||
let c = Vec3::new((app.cam.target.x / major).round() * major, (app.cam.target.y / major).round() * major, 0.0);
|
||
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((c + Vec3::new(t, -ext, 0.0)).to_f32(), (c + Vec3::new(t, ext, 0.0)).to_f32(), col, w);
|
||
sc.under_line((c + Vec3::new(-ext, t, 0.0)).to_f32(), (c + Vec3::new(ext, t, 0.0)).to_f32(), col, w);
|
||
}
|
||
}
|
||
for b in &st.bodies {
|
||
if app.ui.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) {
|
||
continue;
|
||
}
|
||
// With a component active, bodies outside it are drawn faded.
|
||
let faded = s.active_component != 0 && !s.doc.component_within(s.doc.body_component(&b.name, b.feature), s.active_component);
|
||
let base = body_color(&s.doc, b).unwrap_or(tk.body);
|
||
let col = if faded { c4(base.gamma_multiply(0.35)) } else { c4(base) };
|
||
let m = b.mesh();
|
||
let (shaded, edges) = match app.ui.visual_style {
|
||
1 => (true, false),
|
||
2 => (false, true),
|
||
_ => (true, true),
|
||
};
|
||
let edge_col = if shaded { c4(tk.body_edge) } else { c4(tk.text) };
|
||
// Shaded with hidden edges: edges behind the model show faintly.
|
||
let hidden_edges = app.ui.visual_style == 3;
|
||
let hidden_col = c4(tk.text.gamma_multiply(0.35));
|
||
// Appearances: the body's opacity, and faces with looks of their own.
|
||
let opacity = s.doc.body_look(&b.name, b.feature).map_or(1.0, |l| l.opacity);
|
||
let face_looks: std::collections::HashMap<usize, solvecraft_engine::doc::appearance::Look> =
|
||
if shaded { s.doc.face_colors(b).into_iter().collect() } else { Default::default() };
|
||
for (ti, t) in m.triangles.iter().enumerate().filter(|_| shaded) {
|
||
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.seams.get(ei).copied().unwrap_or(false) {
|
||
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);
|
||
}
|
||
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 p in profiles {
|
||
for tri in p.region.triangulate(0.05) {
|
||
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(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) {
|
||
let segs = if !cons {
|
||
pts.windows(2).map(|w| (w[0], w[1])).collect()
|
||
} else if col == colors::SKETCH_CENTERLINE {
|
||
dashes(&pts, &[0.6 * unit, 0.15 * unit, 0.05 * unit, 0.15 * unit])
|
||
} else {
|
||
dashes(&pts, &[0.3 * unit, 0.2 * 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);
|
||
}
|
||
}
|
||
}
|
||
sc
|
||
}
|
||
|
||
/// A soft dark ellipse on the ground under the shown bodies (darkest in the middle).
|
||
fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc::ModelState) {
|
||
let mut b = solvecraft_engine::geom::Aabb3::EMPTY;
|
||
for x in st.bodies.iter().filter(|x| !app.ui.hidden_bodies.contains(&x.name)) {
|
||
b = b.union(&x.mesh().bounds());
|
||
}
|
||
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;
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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();
|
||
}
|
||
let creating = app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::Sketch));
|
||
origin_quads(origin_size(app.cam.half_height()))
|
||
.into_iter()
|
||
.filter(|(n, _, _)| creating || !app.ui.hidden_origin.iter().any(|h| h == n))
|
||
.collect()
|
||
}
|
||
|
||
pub fn origin_axes(app: &SolveApp) -> Vec<(&'static str, Vec3)> {
|
||
if !app.origin_visible() {
|
||
return Vec::new();
|
||
}
|
||
[("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)].into_iter().filter(|(n, _)| !app.ui.hidden_origin.iter().any(|h| h == n)).collect()
|
||
}
|
||
|
||
/// Construction planes drawn as squares around their origin: (name, normal, quad).
|
||
pub fn construction_quads(app: &SolveApp) -> Vec<(String, Vec3, [Vec3; 4])> {
|
||
let h = origin_size(app.cam.half_height()) * 1.2;
|
||
solvecraft_engine::view::construction_planes(&app.session)
|
||
.into_iter()
|
||
.filter(|(_, n, _)| !app.ui.hidden_origin.contains(n))
|
||
.map(|(_, name, pl)| {
|
||
let (o, x, y) = (pl.origin, pl.x * h, pl.y * h);
|
||
(name, pl.normal(), [o - x - y, o + x - y, o + x + y, o - x + y])
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Pick what is under `pos`: sketch points, sketch curves, vertices, edges and axes first, then
|
||
/// the surfaces along the ray (profiles, faces, planes) nearest first.
|
||
/// Bounding boxes of the shown model's bodies (cached until the model changes).
|
||
fn body_bounds(
|
||
app: &SolveApp,
|
||
st: &std::sync::Arc<solvecraft_engine::doc::ModelState>,
|
||
) -> std::sync::Arc<Vec<(String, solvecraft_engine::geom::Aabb3)>> {
|
||
let key = std::sync::Arc::as_ptr(st) as usize;
|
||
if let Ok(mut g) = app.viewport.pick_bounds.lock() {
|
||
if let Some((k, b)) = g.as_ref()
|
||
&& *k == key
|
||
{
|
||
return b.clone();
|
||
}
|
||
let b = std::sync::Arc::new(st.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect::<Vec<_>>());
|
||
*g = Some((key, b.clone()));
|
||
return b;
|
||
}
|
||
std::sync::Arc::new(Vec::new())
|
||
}
|
||
|
||
/// The model's extent: bodies (from the cached boxes) and sketches.
|
||
pub fn model_bounds(app: &SolveApp) -> solvecraft_engine::geom::Aabb3 {
|
||
let st = app.session.world_state();
|
||
let mut b = body_bounds(app, &st).iter().fold(solvecraft_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));
|
||
}
|
||
}
|
||
}
|
||
b
|
||
}
|
||
|
||
/// Where a ray enters a box (None: it misses).
|
||
fn ray_box(o: Vec3, d: Vec3, b: &solvecraft_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: &solvecraft_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.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);
|
||
app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).hash(&mut h);
|
||
app.ui.hidden_bodies.hash(&mut h);
|
||
app.ui.perspective.hash(&mut h);
|
||
(proj.rect.width().to_bits(), proj.rect.height().to_bits()).hash(&mut h);
|
||
let key = h.finish();
|
||
if let Some((k, hits)) = &app.viewport.last_pick
|
||
&& *k == key
|
||
{
|
||
return hits.clone();
|
||
}
|
||
let hits = pick(app, proj, pos);
|
||
app.viewport.last_pick = Some((key, hits.clone()));
|
||
hits
|
||
}
|
||
|
||
pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> 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).
|
||
let wants_points = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::POINTS != 0);
|
||
let point_sketches: Vec<u64> = if wants_points { visible_sketches(app) } else { s.active_sketch.into_iter().collect() };
|
||
let mut best: Option<(f32, Hit)> = None;
|
||
for ss in point_sketches.iter().filter_map(|sid| st.sketch(*sid)) {
|
||
let sid = ss.feature;
|
||
for p in &ss.sketch.points {
|
||
if let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) {
|
||
let dd = sp.distance(pos);
|
||
if dd < 8.0 && best.as_ref().is_none_or(|(b, _)| dd < *b) {
|
||
best = Some((dd, Hit::SketchPoint { sketch: sid, id: p.id.clone(), at: p.pos }));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let Some((_, h)) = best {
|
||
hits.push(h);
|
||
}
|
||
// Sketch curves (the active sketch wins ties).
|
||
let mut bestc: Option<(f32, Hit)> = None;
|
||
for sid in visible_sketches(app) {
|
||
let Some(ss) = st.sketch(sid) else { continue };
|
||
let bonus = if s.active_sketch == Some(sid) { 2.0 } else { 0.0 };
|
||
for (i, c) in ss.sketch.curves.iter().enumerate() {
|
||
if c.link.is_some() && ss.sketch.view.hide_projected {
|
||
continue;
|
||
}
|
||
let straight = matches!(c.kind, CurveKind::Line { .. });
|
||
for seg in ss.sketch.segs(i) {
|
||
let pts: Vec<Pos2> = seg.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect();
|
||
for w in pts.windows(2) {
|
||
let dd = seg_dist(pos, w[0], w[1]) - bonus;
|
||
if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) {
|
||
bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: c.id.clone(), 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.as_ref().is_none_or(|(b, _)| dd < *b) {
|
||
bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: w.id.clone(), straight: false }));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let Some((_, h)) = bestc {
|
||
hits.push(h);
|
||
}
|
||
// Nearest face along the ray (hides what is behind it).
|
||
let mut bestf: Option<(f64, Hit)> = None;
|
||
for b in &st.bodies {
|
||
if app.ui.hidden_bodies.contains(&b.name) {
|
||
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 bestf.as_ref().is_some_and(|(bt, _)| t0 > *bt) => continue,
|
||
_ => {}
|
||
}
|
||
}
|
||
let m = b.mesh();
|
||
if let Some((t, ti)) = m.raycast(o, d)
|
||
&& bestf.as_ref().is_none_or(|(bt, _)| t < *bt)
|
||
{
|
||
let fi = m.tri_face.get(ti).copied().unwrap_or(0) as usize;
|
||
bestf = Some((t, Hit::Face { body: b.name.clone(), index: fi, point: o + d * t }));
|
||
}
|
||
}
|
||
let slack = scene_radius(app) * 0.02;
|
||
let visible = |p: Vec3| bestf.as_ref().is_none_or(|(t, _)| (p - o).dot(d) <= *t + slack);
|
||
// Vertices (edge end points) and edges.
|
||
let mut bestv: Option<(f32, Hit)> = None;
|
||
let mut beste: Option<(f32, Hit)> = None;
|
||
for b in &st.bodies {
|
||
if app.ui.hidden_bodies.contains(&b.name) {
|
||
continue;
|
||
}
|
||
// 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.seams.get(ei).copied().unwrap_or(false) {
|
||
continue;
|
||
}
|
||
for v in [e.first(), e.last()].into_iter().flatten() {
|
||
if let Some(sp) = proj.to_screen(*v) {
|
||
let dd = sp.distance(pos);
|
||
if dd < 7.0 && visible(*v) && bestv.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||
bestv = Some((dd, Hit::Vertex { body: b.name.clone(), point: *v }));
|
||
}
|
||
}
|
||
}
|
||
let pts: Vec<Pos2> = e.iter().filter_map(|q| proj.to_screen(*q)).collect();
|
||
for (k, w) in pts.windows(2).enumerate() {
|
||
let dd = seg_dist(pos, w[0], w[1]);
|
||
if dd < 6.0 && beste.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||
let mid = polyline_mid(e);
|
||
// Visible where the cursor is (not just at the middle).
|
||
let at = e.get(k).copied().unwrap_or(mid);
|
||
if visible(at) || visible(mid) {
|
||
beste = Some((dd, Hit::Edge { body: b.name.clone(), index: ei, mid }));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
hits.extend(bestv.map(|x| x.1));
|
||
hits.extend(beste.map(|x| x.1));
|
||
// Origin axes.
|
||
let size = origin_size(app.cam.half_height());
|
||
let mut besta: Option<(f32, Hit)> = None;
|
||
for (name, dir) in origin_axes(app) {
|
||
if let (Some(a), Some(b)) = (proj.to_screen(Vec3::ZERO), proj.to_screen(dir * (size * AXIS_LEN))) {
|
||
let dd = seg_dist(pos, a, b);
|
||
if dd < 5.0 && besta.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||
besta = Some((dd, Hit::Axis { name: name.into() }));
|
||
}
|
||
}
|
||
}
|
||
hits.extend(besta.map(|x| x.1));
|
||
// Surfaces by depth: profiles (slightly preferred over a face they lie on), faces, planes.
|
||
let mut surf: Vec<(f64, Hit)> = Vec::new();
|
||
let mut bestp: Option<(f64, f64, Hit)> = None;
|
||
for sid in visible_sketches(app) {
|
||
let Some(ss) = st.sketch(sid) else { continue };
|
||
let n = ss.plane.normal();
|
||
let den = n.dot(d);
|
||
if den.abs() < 1e-12 {
|
||
continue;
|
||
}
|
||
let t = n.dot(ss.plane.origin - o) / den;
|
||
if t < 0.0 {
|
||
continue;
|
||
}
|
||
let lp = ss.plane.to_local(o + d * t);
|
||
// 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) && bestp.as_ref().is_none_or(|(_, a, _)| p.area < *a) {
|
||
bestp = Some((t, p.area, Hit::Profile { sketch: sid, index: pi }));
|
||
}
|
||
}
|
||
}
|
||
if let Some((t, _, h)) = bestp {
|
||
surf.push((t - slack, h));
|
||
}
|
||
if let Some(f) = bestf {
|
||
surf.push(f);
|
||
}
|
||
for (name, _, q) in origin_planes(app) {
|
||
if let Some(t) = ray_quad(o, d, &q) {
|
||
surf.push((t, Hit::Plane { name: name.into(), point: o + d * t }));
|
||
}
|
||
}
|
||
for (name, _, q) in construction_quads(app) {
|
||
if let Some(t) = ray_quad(o, d, &q) {
|
||
surf.push((t, Hit::Plane { name, point: o + d * t }));
|
||
}
|
||
}
|
||
surf.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||
hits.extend(surf.into_iter().map(|x| x.1));
|
||
hits
|
||
}
|
||
|
||
/// 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) -> solvecraft_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
|
||
}
|
||
|
||
/// 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<solvecraft_engine::geom::Aabb3> {
|
||
let st = app.session.world_state();
|
||
let mut b = solvecraft_engine::geom::Aabb3::EMPTY;
|
||
for s in &app.session.selection {
|
||
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::Feature { id } => {
|
||
for x in st.bodies.iter().filter(|x| x.feature == *id) {
|
||
b = b.union(&x.mesh().bounds());
|
||
}
|
||
}
|
||
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 solvecraft_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 view cube (docked dialogs start below).
|
||
pub const VIEW_CUBE_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()) {
|
||
return hits.iter().find_map(|h| d.candidate(&app.session, h).map(|s| (h.clone(), s)));
|
||
}
|
||
let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k);
|
||
hits.iter()
|
||
.filter(|h| match h {
|
||
Hit::SketchCurve { sketch, .. } => app.session.active_sketch == Some(*sketch) && !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,
|
||
})
|
||
.find_map(|h| match h {
|
||
Hit::Face { body, .. } if app.ui.pick_bodies => Some((h.clone(), Sel::Body { name: body.clone() })),
|
||
_ => hit_sel(h).map(|s| (h.clone(), s)),
|
||
})
|
||
}
|
||
|
||
/// The edge a selection refers to now: by index if it still passes through the stored point,
|
||
/// otherwise the edge nearest the point.
|
||
fn edge_of(m: &solvecraft_engine::geom::Mesh, index: usize, point: Vec3) -> Option<&Vec<Vec3>> {
|
||
let tol = m.bounds().diagonal().max(1.0) * 1e-6;
|
||
let near = |e: &Vec<Vec3>| e.windows(2).map(|w| point.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||
if let Some(e) = m.edges.get(index)
|
||
&& near(e) < tol
|
||
{
|
||
return Some(e);
|
||
}
|
||
m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).filter(|e| near(e) < tol * 1e3)
|
||
}
|
||
|
||
fn face_tris(sc: &mut GpuScene, m: &solvecraft_engine::geom::Mesh, face: usize, col: [u8; 4], lit: bool) {
|
||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||
if *f as usize != face {
|
||
continue;
|
||
}
|
||
for k in t {
|
||
let i = *k as usize;
|
||
if let (Some(p), Some(n)) = (m.positions.get(i), m.normals.get(i)) {
|
||
sc.tri(p.to_f32(), if lit { n.to_f32() } else { [0.0; 3] }, col);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn edge_lines(sc: &mut GpuScene, e: &[Vec3], core: Color32, halo: Color32, w: f32) {
|
||
for (col, width) in [(halo, w + 3.0), (core, w)] {
|
||
for p in e.windows(2) {
|
||
sc.line(p[0].to_f32(), p[1].to_f32(), c4(col), width, false);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) {
|
||
let st = app.session.world_state();
|
||
let Some(ss) = st.sketch(sketch) else { return };
|
||
let Some(p) = ss.profiles.get(index) else { return };
|
||
// Lift toward the eye so a profile on a face shows over it.
|
||
let lift = app.cam.back() * (app.cam.half_height() * 0.002);
|
||
let w = |q: solvecraft_engine::geom::Vec2| (ss.plane.to_world(q) + lift).to_f32();
|
||
let col = c4(fill);
|
||
// In the translucent pass (after the grid and edges) so it covers them.
|
||
for [a, b, c] in p.region.triangulate(0.02) {
|
||
sc.trans_tri(w(a), [0.0; 3], col);
|
||
sc.trans_tri(w(b), [0.0; 3], col);
|
||
sc.trans_tri(w(c), [0.0; 3], col);
|
||
}
|
||
for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) {
|
||
let poly = lp.polyline(0.02);
|
||
let n = poly.len();
|
||
for i in 0..n {
|
||
if let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) {
|
||
sc.line(w(*a), w(*b), c4(edge), 2.0, false);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn quad(sc: &mut GpuScene, q: &[Vec3; 4], fill: Color32, edge: Color32) {
|
||
let col = c4(fill);
|
||
for i in [0, 1, 2, 0, 2, 3] {
|
||
if let Some(p) = q.get(i) {
|
||
sc.trans_tri(p.to_f32(), [0.0; 3], col);
|
||
}
|
||
}
|
||
for i in 0..4 {
|
||
sc.line(q[i].to_f32(), q[(i + 1) % 4].to_f32(), c4(edge), 1.2, false);
|
||
}
|
||
}
|
||
|
||
fn highlight_key(app: &SolveApp) -> u64 {
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
app.session.revision.hash(&mut h);
|
||
serde_json::to_string(&app.highlighted()).unwrap_or_default().hash(&mut h);
|
||
format!("{:?}", app.viewport.hover).hash(&mut h);
|
||
app.viewport.hover_feature.hash(&mut h);
|
||
app.viewport.hover_bodies.hash(&mut h);
|
||
app.ui.pick_bodies.hash(&mut h);
|
||
crate::theme::is_dark().hash(&mut h);
|
||
app.preview.replaced.hash(&mut h);
|
||
app.origin_visible().hash(&mut h);
|
||
app.ui.hidden_origin.hash(&mut h);
|
||
app.ui.hidden_bodies.hash(&mut h);
|
||
app.cam.half_height().to_bits().hash(&mut h);
|
||
app.cam.back().x.to_bits().hash(&mut h);
|
||
h.finish()
|
||
}
|
||
|
||
/// The highlight scene: origin widget, construction planes, selection and hover.
|
||
fn build_highlight(app: &SolveApp) -> GpuScene {
|
||
let t = Tokens::get();
|
||
let mut sc = GpuScene::default();
|
||
let st = app.session.world_state();
|
||
let sel = app.highlighted();
|
||
let hover = app.viewport.hover.as_ref();
|
||
let size = origin_size(app.cam.half_height());
|
||
// Origin planes and construction planes.
|
||
let plane_state = |name: &str| {
|
||
let selected = sel.iter().any(|x| matches!(x, Sel::Plane { name: n } if n == name));
|
||
let hovered = matches!(hover, Some(Hit::Plane { name: n, .. }) if n == name);
|
||
(selected, hovered)
|
||
};
|
||
let mut planes: Vec<(String, [Vec3; 4], Color32)> = origin_planes(app).into_iter().map(|(n, _, q)| (n.to_string(), q, t.origin_plane)).collect();
|
||
planes.extend(construction_quads(app).into_iter().map(|(n, _, q)| (n, q, t.construction_plane)));
|
||
for (name, q, base) in &planes {
|
||
let (selected, hovered) = plane_state(name);
|
||
let fill = if hovered {
|
||
t.origin_plane_hover
|
||
} else if selected {
|
||
Color32::from_rgba_unmultiplied(t.sel_face.r(), t.sel_face.g(), t.sel_face.b(), 150)
|
||
} else {
|
||
*base
|
||
};
|
||
quad(&mut sc, q, fill, if selected { t.sel_edge } else { t.origin_plane_edge });
|
||
}
|
||
// Origin axes: solid near the origin, dashed beyond.
|
||
for (name, dir) in origin_axes(app) {
|
||
let col = match name {
|
||
"X" => t.axis_x,
|
||
"Y" => t.axis_y,
|
||
_ => t.axis_z,
|
||
};
|
||
let on = sel.iter().any(|x| matches!(x, Sel::Axis { name: n } if n == name)) || matches!(hover, Some(Hit::Axis { name: n }) if n == name);
|
||
let w = if on { 4.0 } else { 2.0 };
|
||
sc.line([0.0; 3], (dir * size).to_f32(), c4(col), w, false);
|
||
let dash = size * 0.08;
|
||
let mut a = size + dash;
|
||
while a < size * AXIS_LEN {
|
||
sc.line((dir * a).to_f32(), (dir * (a + dash)).to_f32(), c4(col), w * 0.75, false);
|
||
a += dash * 2.0;
|
||
}
|
||
}
|
||
// Selected things.
|
||
for x in &sel {
|
||
match x {
|
||
Sel::Face { body, index, .. } => {
|
||
// A live preview stands in for the body: its old faces would fight with it.
|
||
if let Some(b) = st.body(body).filter(|_| !app.preview.replaced.contains(body)) {
|
||
face_tris(&mut sc, &b.mesh(), *index, c4(t.sel_face), false);
|
||
}
|
||
}
|
||
Sel::Body { name } => {
|
||
if let Some(b) = st.body(name)
|
||
&& !app.ui.hidden_bodies.contains(name)
|
||
{
|
||
let m = b.mesh();
|
||
for f in 0..b.body.face_count() {
|
||
face_tris(&mut sc, &m, f, c4(t.body_selected), true);
|
||
}
|
||
}
|
||
}
|
||
Sel::Edge { body, index, point } => {
|
||
if let Some(b) = st.body(body)
|
||
&& let Some(e) = edge_of(&b.mesh(), *index, *point)
|
||
{
|
||
edge_lines(&mut sc, e, t.sel_edge, t.sel_edge_rim, 3.0);
|
||
}
|
||
}
|
||
Sel::Profile { sketch, index } => profile_fill(&mut sc, app, *sketch, *index, t.sel_profile, t.sel_profile_edge),
|
||
// 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 seg in ss.sketch.segs(ci) {
|
||
let pts = seg.polyline(0.02);
|
||
for w in pts.windows(2) {
|
||
sc.line(ss.plane.to_world(w[0]).to_f32(), ss.plane.to_world(w[1]).to_f32(), c4(t.sel_edge), 3.5, true);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
// Bodies made by the timeline item under the cursor, or whose browser row is.
|
||
if app.viewport.hover_feature.is_some() || !app.viewport.hover_bodies.is_empty() {
|
||
let k = t.hover_face_lift;
|
||
let lit =
|
||
|b: &solvecraft_engine::doc::ModelBody| Some(b.feature) == app.viewport.hover_feature || app.viewport.hover_bodies.contains(&b.name);
|
||
for b in st.bodies.iter().filter(|b| lit(b) && !app.ui.hidden_bodies.contains(&b.name)) {
|
||
let c = body_rgb(app, &b.name);
|
||
let m = b.mesh();
|
||
for f in 0..b.body.face_count() {
|
||
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
|
||
}
|
||
for (ei, e) in m.edges.iter().enumerate() {
|
||
if !m.seams.get(ei).copied().unwrap_or(false) {
|
||
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 1.5);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Hover (pre-highlight).
|
||
match hover {
|
||
// Body selection: the whole body lights up.
|
||
Some(Hit::Face { body, .. }) if app.ui.pick_bodies && app.dialog.is_none() && app.tool.is_none() => {
|
||
if let Some(b) = st.body(body) {
|
||
let k = t.hover_face_lift;
|
||
let c = body_rgb(app, body);
|
||
let m = b.mesh();
|
||
for f in 0..b.body.face_count() {
|
||
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
|
||
}
|
||
}
|
||
}
|
||
Some(Hit::Face { body, index, .. })
|
||
if !app.preview.replaced.contains(body)
|
||
&& !sel.iter().any(|x| matches!(x, Sel::Face { body: b, index: i, .. } if b == body && i == index)) =>
|
||
{
|
||
if let Some(b) = st.body(body) {
|
||
let k = t.hover_face_lift;
|
||
let c = body_rgb(app, body);
|
||
face_tris(&mut sc, &b.mesh(), *index, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
|
||
let m = b.mesh();
|
||
for e in m.face_edges(u32::try_from(*index).unwrap_or(u32::MAX)) {
|
||
if let Some(p) = m.edges.get(e)
|
||
&& !m.seams.get(e).copied().unwrap_or(false)
|
||
{
|
||
edge_lines(&mut sc, p, t.hover_edge, t.hover_edge_halo, 1.5);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Some(Hit::Edge { body, index, mid }) => {
|
||
if let Some(b) = st.body(body)
|
||
&& let Some(e) = edge_of(&b.mesh(), *index, *mid)
|
||
{
|
||
let selected = sel.iter().any(|x| matches!(x, Sel::Edge { point, .. } if point.dist(*mid) < 1e-9));
|
||
if !selected {
|
||
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 2.0);
|
||
}
|
||
}
|
||
}
|
||
Some(Hit::Profile { sketch, index })
|
||
if !sel.iter().any(|x| matches!(x, Sel::Profile { sketch: s2, index: i2 } if s2 == sketch && i2 == index)) =>
|
||
{
|
||
profile_fill(&mut sc, app, *sketch, *index, t.hover_profile, t.hover_profile_edge);
|
||
}
|
||
_ => {}
|
||
}
|
||
sc
|
||
}
|
||
|
||
/// A body's display colour (appearance, material or imported colour), if it has one.
|
||
fn body_color(doc: &solvecraft_engine::doc::Document, b: &solvecraft_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())
|
||
}
|
||
|
||
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 { id, at, .. } = h {
|
||
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);
|
||
}
|
||
|
||
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get();
|
||
let rect = ui.max_rect();
|
||
app.viewport.rect = Some(rect);
|
||
let resp = ui.interact(rect, ui.id().with("viewport"), Sense::click_and_drag());
|
||
let (hover, scroll, mods, middle, secondary_down, primary_down, 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.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 view cube handles its own clicks; the model underneath must not see them.
|
||
let cube = Rect::from_center_size(pos2(rect.right() - 80.0, rect.top() + 80.0), vec2(150.0, 150.0));
|
||
let inside = hover.is_some_and(|p| rect.contains(p) && !cube.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);
|
||
|
||
// ---- navigation ----
|
||
let h = rect.height().max(1.0) as f64;
|
||
if inside && scroll != 0.0 {
|
||
let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp();
|
||
let anchor = hover.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 }
|
||
});
|
||
app.cancel_view_animation();
|
||
app.cam.zoom_at(f, anchor);
|
||
}
|
||
let dragging = resp.dragged() || (inside && (middle || secondary_down));
|
||
if dragging && delta != egui::Vec2::ZERO {
|
||
let (dx, dy) = (delta.x as f64, delta.y as f64);
|
||
let mode = if middle || secondary_down {
|
||
crate::prefs::nav_mode(app, middle, secondary_down, mods)
|
||
} else if primary_down {
|
||
app.viewport.nav
|
||
} else {
|
||
None
|
||
};
|
||
if mode.is_some() {
|
||
app.cancel_view_animation();
|
||
}
|
||
match mode {
|
||
Some(NavMode::Orbit) => {
|
||
let press = ui.input(|i| i.pointer.press_origin());
|
||
crate::prefs::orbit(app, rect, dx, dy, press);
|
||
}
|
||
Some(NavMode::Pan) => app.cam.pan(dx, dy, h),
|
||
Some(NavMode::Zoom) => app.cam.zoom_at((dy * 0.01).exp(), None),
|
||
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 = 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.cam = Camera::default();
|
||
app.fit_view();
|
||
}
|
||
|
||
let proj = projection(app, rect);
|
||
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 hk = highlight_key(app);
|
||
if hk != app.viewport.hl_key {
|
||
let hl = build_highlight(app);
|
||
if let Ok(mut slot) = app.viewport.hl_slot.lock() {
|
||
*slot = Some(hl);
|
||
}
|
||
app.viewport.hl_key = hk;
|
||
}
|
||
let ppp = ui.ctx().pixels_per_point();
|
||
let cb = ViewportCallback {
|
||
key: app.viewport.key,
|
||
slot: app.viewport.slot.clone(),
|
||
hl_key: app.viewport.hl_key,
|
||
hl_slot: app.viewport.hl_slot.clone(),
|
||
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),
|
||
};
|
||
painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb));
|
||
} else {
|
||
cpu_render(app, ui.ctx(), &painter, rect, &proj);
|
||
}
|
||
|
||
// ---- interaction ----
|
||
// No picking while the view moves (orbit, pan, zoom): nothing can be hovered meanwhile.
|
||
let navigating = scroll != 0.0 || ((middle || secondary_down) && delta != egui::Vec2::ZERO) || (app.viewport.nav.is_some() && primary_down);
|
||
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default();
|
||
let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) };
|
||
app.viewport.hover = cand.as_ref().map(|c| c.0.clone());
|
||
let add = mods.shift || mods.command || mods.ctrl;
|
||
if let Some(p) = hover.filter(|_| inside) {
|
||
if app.tool.is_some() {
|
||
crate::tools::on_hover(app, &proj, p);
|
||
}
|
||
if resp.clicked() {
|
||
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(sel) = cand.and_then(|c| c.1)
|
||
&& let Some(mut d) = app.dialog.take()
|
||
{
|
||
d.pick(&app.session, sel);
|
||
app.dialog = Some(d);
|
||
}
|
||
} 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::sketch3d::click(app, &proj, p) {
|
||
match on_dim.or(on_glyph) {
|
||
Some(id) => select(app, Some(Sel::SketchConstraint { id }), add),
|
||
None => select(app, cand.and_then(|c| c.1), add),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Double-click a dimension: edit its value in place.
|
||
let dim_edit = resp.double_clicked() && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p);
|
||
// Double-click a sketch curve: select the chain joined to it end to end.
|
||
if resp.double_clicked()
|
||
&& !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()
|
||
&& !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() && app.viewport.nav.is_none() && app.session.active_sketch.is_some() {
|
||
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))
|
||
};
|
||
// The 3D triad, then a dimension's text, follow a drag that starts on them.
|
||
if resp.drag_started_by(egui::PointerButton::Primary)
|
||
&& let Some(a) = ui.input(|i| i.pointer.press_origin())
|
||
&& !crate::sketch3d::drag_start(app, &proj, a)
|
||
&& let Some(q) = on_plane(app, a)
|
||
{
|
||
crate::dim_view::drag_start(app, a, q);
|
||
}
|
||
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::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 resp.drag_started_by(egui::PointerButton::Primary)
|
||
&& !crate::dim_view::dragging()
|
||
&& !crate::sketch3d::dragging()
|
||
&& let Some(a) = ui.input(|i| i.pointer.press_origin())
|
||
{
|
||
let active = app.session.active_sketch;
|
||
match pick(app, &proj, a).into_iter().next() {
|
||
Some(Hit::SketchPoint { id, .. }) => 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, 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;
|
||
}
|
||
if app.session.execute("sketch.drag", &json!({"entity": id, "from": [from.x, from.y], "to": [q.x, q.y]})).is_ok() {
|
||
app.session.undo.truncate(depth + 1);
|
||
}
|
||
if grab.is_some() {
|
||
app.viewport.point_drag = Some((id, depth, Some(q)));
|
||
}
|
||
}
|
||
if resp.drag_stopped() {
|
||
if let Some((_, depth, _)) = app.viewport.point_drag.clone() {
|
||
// Let go on a point, midpoint or curve: the matching constraint, in the drag's undo step.
|
||
crate::drag_snap::release(app);
|
||
app.session.undo.truncate(depth + 1);
|
||
}
|
||
crate::drag_snap::clear();
|
||
app.viewport.point_drag = None;
|
||
crate::dim_view::drag_end();
|
||
crate::sketch3d::drag_end();
|
||
}
|
||
}
|
||
// Box selection: a primary drag on the model when no navigation mode is on.
|
||
if app.tool.is_none()
|
||
&& app.viewport.nav.is_none()
|
||
&& app.viewport.point_drag.is_none()
|
||
&& !crate::dim_view::dragging()
|
||
&& !crate::sketch3d::dragging()
|
||
{
|
||
let origin = ui.input(|i| i.pointer.press_origin());
|
||
if resp.dragged_by(egui::PointerButton::Primary)
|
||
&& let (Some(a), Some(b)) = (origin, hover)
|
||
&& rect.contains(a)
|
||
&& !cube.contains(a)
|
||
{
|
||
app.viewport.boxsel = Some(BoxSel::from_drag(a, b));
|
||
}
|
||
if resp.drag_stopped()
|
||
&& let Some(bx) = app.viewport.boxsel.take()
|
||
{
|
||
box_select(app, &proj, bx, add);
|
||
}
|
||
} else {
|
||
app.viewport.boxsel = None;
|
||
}
|
||
crate::ref_images::show(app, ui, &painter, &proj);
|
||
overlays(app, &painter, &proj);
|
||
crate::dim_view::show(app, ui, &painter, &proj);
|
||
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(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);
|
||
view_cube(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.
|
||
fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
|
||
match sel {
|
||
Some(x) => {
|
||
let item = serde_json::to_value(&x).unwrap_or_default();
|
||
if add && app.session.selection.contains(&x) {
|
||
let rest: Vec<Sel> = app.session.selection.iter().filter(|y| **y != x).cloned().collect();
|
||
let _ = app.run("select.set", json!({ "items": rest }));
|
||
} else {
|
||
let _ = app.run("select.set", json!({"items": [item], "add": add}));
|
||
}
|
||
}
|
||
None if !add => {
|
||
let _ = app.run("select.clear", json!({}));
|
||
}
|
||
None => {}
|
||
}
|
||
}
|
||
|
||
/// 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() };
|
||
if app.dialog.is_none()
|
||
&& 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 c.link.is_some() && ss.sketch.view.hide_projected {
|
||
continue;
|
||
}
|
||
let pts: Vec<Vec3> = ss.sketch.segs(i).iter().flat_map(|sg| sg.polyline(0.05)).map(|q| ss.plane.to_world(q)).collect();
|
||
if bx.takes(&to2(&pts)) {
|
||
out.push(Sel::SketchCurve { id: c.id.clone() });
|
||
}
|
||
}
|
||
for (i, p) in ss.sketch.points.iter().enumerate().filter(|_| !off("sketch") && !off("sketch_points")) {
|
||
if i != 0 && solvecraft_engine::view::sketch_point_visible(&ss.sketch, i) && bx.takes(&to2(&[ss.plane.to_world(p.pos)])) {
|
||
out.push(Sel::SketchPoint { id: p.id.clone() });
|
||
}
|
||
}
|
||
if !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 !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.ui.hidden_bodies.contains(&b.name) {
|
||
continue;
|
||
}
|
||
let m = b.mesh();
|
||
if accept & EDGES != 0 {
|
||
for (ei, e) in m.edges.iter().enumerate() {
|
||
if !m.seams.get(ei).copied().unwrap_or(false) && bx.takes(&to2(e)) {
|
||
out.push(Sel::Edge { body: b.name.clone(), index: ei, point: polyline_mid(e) });
|
||
}
|
||
}
|
||
} else if accept & FACES != 0 {
|
||
for f in 0..b.body.face_count() {
|
||
let mut pts: Vec<Vec3> = Vec::new();
|
||
for e in m.face_edges(u32::try_from(f).unwrap_or(u32::MAX)) {
|
||
pts.extend(m.edges.get(e).into_iter().flatten().copied());
|
||
}
|
||
let point = m
|
||
.triangles
|
||
.iter()
|
||
.zip(&m.tri_face)
|
||
.find(|(_, tf)| **tf as usize == f)
|
||
.and_then(|(t, _)| m.tri(t))
|
||
.map(|[a, bb, c]| (a + bb + c) / 3.0);
|
||
if let Some(point) = point
|
||
&& bx.takes(&to2(&pts))
|
||
{
|
||
out.push(Sel::Face { body: b.name.clone(), index: f, point });
|
||
}
|
||
}
|
||
} else if accept & BODIES != 0 {
|
||
let crossing_hit = m.edges.iter().any(|e| bx.takes(&to2(e)));
|
||
let all_in = m.edges.iter().all(|e| bx.takes(&to2(e)));
|
||
if (bx.crossing && crossing_hit) || (!bx.crossing && all_in && !m.edges.is_empty()) {
|
||
out.push(Sel::Body { name: b.name.clone() });
|
||
}
|
||
} else if accept & PROFILES != 0 {
|
||
for sid in visible_sketches(app) {
|
||
let Some(ss) = st.sketch(sid) else { continue };
|
||
for (pi, p) in ss.profiles.iter().enumerate() {
|
||
let pts: Vec<Vec3> = p.region.outer.polyline(0.05).into_iter().map(|q| ss.plane.to_world(q)).collect();
|
||
if bx.takes(&to2(&pts)) {
|
||
out.push(Sel::Profile { sketch: sid, index: pi });
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if let Some(mut d) = app.dialog.take() {
|
||
d.take_box(out, add);
|
||
app.dialog = Some(d);
|
||
return;
|
||
}
|
||
let items = serde_json::to_value(&out).unwrap_or_default();
|
||
let _ = app.run("select.set", json!({"items": items, "add": add}));
|
||
}
|
||
|
||
/// Screen-space dots: the origin point, and hovered or selected body vertices.
|
||
fn points_2d(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||
let t = Tokens::get();
|
||
if app.origin_visible()
|
||
&& !app.ui.hidden_origin.iter().any(|h| h == "O")
|
||
&& let Some(o) = proj.to_screen(Vec3::ZERO)
|
||
{
|
||
painter.circle(o, 4.5, t.origin_point, Stroke::new(1.0, Color32::from_gray(110)));
|
||
}
|
||
for x in app.highlighted() {
|
||
if let Sel::Vertex { point, .. } = x
|
||
&& let Some(p) = proj.to_screen(point)
|
||
{
|
||
painter.circle(p, 4.0, t.sel_vertex, Stroke::new(1.5, t.sel_edge));
|
||
}
|
||
}
|
||
if let Some(Hit::Vertex { point, .. }) = &app.viewport.hover
|
||
&& let Some(p) = proj.to_screen(*point)
|
||
{
|
||
painter.circle(p, 4.5, t.hover_edge_halo, Stroke::new(1.5, t.hover_edge));
|
||
}
|
||
}
|
||
|
||
/// Hover feedback for the active sketch's points and curves (drawn on top of everything).
|
||
fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||
let col = Tokens::get().hover_profile_edge;
|
||
let st = app.session.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 seg in ss.sketch.segs(ci) {
|
||
let pts: Vec<Pos2> = seg.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect();
|
||
painter.add(Shape::line(pts, 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, dimension labels and constraint glyphs of the active sketch.
|
||
fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||
let t = Tokens::get();
|
||
let st = app.session.world_state();
|
||
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();
|
||
for (i, p) in sk.points.iter().enumerate() {
|
||
if !solvecraft_engine::view::sketch_point_visible(sk, i) {
|
||
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 {
|
||
painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c);
|
||
}
|
||
}
|
||
for c in sk.constraints.iter().filter(|c| !c.kind.is_dimension() && !sk.view.hide_constraints) {
|
||
glyph(painter, proj, ss, &c.kind);
|
||
}
|
||
// 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 line_mid(sk: &solvecraft_engine::sketch::Sketch, l: usize) -> Option<(Vec2, Vec2)> {
|
||
match sk.curves.get(l)?.kind {
|
||
CurveKind::Line { a, b } => Some((sk.point(a)?, sk.point(b)?)),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Small constraint markers next to the constrained geometry.
|
||
fn glyph(painter: &egui::Painter, proj: &Proj, ss: &solvecraft_engine::doc::SolvedSketch, k: &ConstraintKind) {
|
||
use ConstraintKind::*;
|
||
let sk = &ss.sketch;
|
||
let (anchor, label) = match *k {
|
||
Horizontal { l } => (line_mid(sk, l).map(|(a, b)| (a + b) * 0.5), "H"),
|
||
Vertical { l } => (line_mid(sk, l).map(|(a, b)| (a + b) * 0.5), "V"),
|
||
Parallel { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5), "//"),
|
||
Perpendicular { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5), "L"),
|
||
Tangent { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5).or_else(|| sk.center(a)), "T"),
|
||
Equal { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5).or_else(|| sk.center(a)), "="),
|
||
Fix { p } => (sk.point(p), "F"),
|
||
_ => (None, ""),
|
||
};
|
||
let Some(a) = anchor.and_then(|a| proj.to_screen(ss.plane.to_world(a))) else { return };
|
||
let r = Rect::from_center_size(a + vec2(10.0, -10.0), vec2(13.0, 13.0));
|
||
let t = Tokens::get();
|
||
painter.rect_filled(r, 2.0, t.glyph_bg);
|
||
painter.rect_stroke(r, 2.0, Stroke::new(1.0, t.glyph_edge), egui::StrokeKind::Inside);
|
||
painter.text(r.center(), Align2::CENTER_CENTER, label, FontId::proportional(10.0), t.glyph_text);
|
||
}
|
||
|
||
fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, rect: Rect, proj: &Proj) {
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
scene_key(app).hash(&mut h);
|
||
for v in [proj.cam.yaw, proj.cam.pitch, 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 scene = solvecraft_engine::view::scene(&app.session, &proj.cam);
|
||
let w = (rect.width() as usize).clamp(16, 4096);
|
||
let hh = (rect.height() as usize).clamp(16, 4096);
|
||
let c = solvecraft_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);
|
||
}
|
||
}
|
||
|
||
/// Where a point in view-cube units (faces at ±1) is drawn.
|
||
pub(crate) fn cube_point(app: &SolveApp, rect: Rect, p: Vec3) -> Pos2 {
|
||
let (r, u, _) = app.cam.basis();
|
||
pos2(rect.right() - 80.0 + (p.dot(r) * CUBE_SCALE) as f32, rect.top() + 80.0 - (p.dot(u) * CUBE_SCALE) as f32)
|
||
}
|
||
|
||
/// Half a view cube face, in pixels.
|
||
const CUBE_SCALE: f64 = 32.0;
|
||
|
||
/// The view cube (top right): click a face for a standard view; the house resets.
|
||
fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||
let t = Tokens::get();
|
||
let c = pos2(rect.right() - 80.0, rect.top() + 80.0);
|
||
let s = CUBE_SCALE;
|
||
let mut lit = 0;
|
||
let (r, u, b) = app.cam.basis();
|
||
let to2 = |p: Vec3| -> (Pos2, f64) { (pos2(c.x + (p.dot(r) * s) as f32, c.y - (p.dot(u) * s) as f32), p.dot(b)) };
|
||
let faces: [(Vec3, &str, StandardView); 6] = [
|
||
(Vec3::new(0.0, -1.0, 0.0), "FRONT", StandardView::Front),
|
||
(Vec3::new(0.0, 1.0, 0.0), "BACK", StandardView::Back),
|
||
(Vec3::new(1.0, 0.0, 0.0), "RIGHT", StandardView::Right),
|
||
(Vec3::new(-1.0, 0.0, 0.0), "LEFT", StandardView::Left),
|
||
(Vec3::Z, "TOP", StandardView::Top),
|
||
(Vec3::new(0.0, 0.0, -1.0), "BOTTOM", StandardView::Bottom),
|
||
];
|
||
let painter = ui.painter_at(rect);
|
||
let hover = ui.input(|i| i.pointer.hover_pos());
|
||
let mut order: Vec<usize> = (0..6).collect();
|
||
order.sort_by(|a, b2| faces[*a].0.dot(b).total_cmp(&faces[*b2].0.dot(b)));
|
||
let visible: Vec<usize> = order.into_iter().filter(|i| faces[*i].0.dot(b) > 1e-3).collect();
|
||
let outline = |n: Vec3| -> Vec<Pos2> {
|
||
let (e1, e2) = cube_face_axes(n);
|
||
[n - e1 - e2, n + e1 - e2, n + e1 + e2, n - e1 + e2].iter().map(|p| to2(*p).0).collect()
|
||
};
|
||
// The region under the cursor (a face, edge or corner, as the view direction a click
|
||
// turns to): found on the frontmost face under it, the same zones a click uses.
|
||
let region: Option<Vec3> = hover.and_then(|h| {
|
||
visible.iter().rev().find_map(|i| {
|
||
let n = faces[*i].0;
|
||
let pts = outline(n);
|
||
let (e1, e2) = cube_face_axes(n);
|
||
point_in_poly(h, &pts).then(|| cube_zone(&pts, h)).flatten().map(|(zi, zj)| n + e1 * f64::from(zi) + e2 * f64::from(zj))
|
||
})
|
||
});
|
||
for i in &visible {
|
||
let (n, label, _) = faces[*i];
|
||
let pts = outline(n);
|
||
let shade = (0.75 + 0.25 * n.dot(b)) as f32;
|
||
painter.add(Shape::convex_polygon(pts, t.cube_face.gamma_multiply(shade).to_opaque(), Stroke::new(1.0, t.cube_edge)));
|
||
// Every patch of this face that belongs to the hovered region: a corner lights its
|
||
// three patches, an edge its two strips, a face its middle.
|
||
let (e1, e2) = cube_face_axes(n);
|
||
for (zi, zj) in region.map(|d| cube_patches_on(n, d)).unwrap_or_default() {
|
||
let lo = |k: i32| match k {
|
||
-1 => (-1.0, -1.0 + 2.0 * CUBE_BAND),
|
||
0 => (-1.0 + 2.0 * CUBE_BAND, 1.0 - 2.0 * CUBE_BAND),
|
||
_ => (1.0 - 2.0 * CUBE_BAND, 1.0),
|
||
};
|
||
let ((a0, a1), (b0, b1)) = (lo(zi), lo(zj));
|
||
let zp: Vec<Pos2> = [(a0, b0), (a1, b0), (a1, b1), (a0, b1)].iter().map(|(x, y)| to2(n + e1 * *x + e2 * *y).0).collect();
|
||
painter.add(Shape::convex_polygon(zp, t.accent_soft, Stroke::NONE));
|
||
lit += 1;
|
||
}
|
||
let center = to2(n).0;
|
||
if n.dot(b) > 0.35 {
|
||
painter.text(center, Align2::CENTER_CENTER, label, FontId::proportional(9.5), Color32::from_rgb(40, 44, 52));
|
||
}
|
||
}
|
||
// Clicked view direction (target → eye): the hovered region.
|
||
let clicked = region.filter(|_| ui.input(|i| i.pointer.primary_clicked()));
|
||
crate::scenario::publish_count("cube_highlights", f64::from(lit));
|
||
// Axis triad at the cube's corner.
|
||
let o = Vec3::new(-1.0, -1.0, -1.0);
|
||
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
|
||
let (a, _) = to2(o);
|
||
let (bb, _) = to2(o + d * 2.6);
|
||
painter.line_segment([a, bb], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2)));
|
||
}
|
||
let home = Rect::from_center_size(pos2(c.x - 52.0, c.y - 48.0), vec2(18.0, 18.0));
|
||
let hr = ui.interact(home, ui.id().with("vc_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("Home view").clicked() {
|
||
app.animate_view("home");
|
||
}
|
||
if let Some(dir) = clicked {
|
||
let to = app.cam.looking_from(dir);
|
||
app.animate_to(to);
|
||
}
|
||
}
|
||
|
||
/// The in-face axes of a view cube face (the zone grid's u and v).
|
||
fn cube_face_axes(n: Vec3) -> (Vec3, Vec3) {
|
||
if n.z.abs() > 0.5 {
|
||
(Vec3::X, Vec3::Y)
|
||
} else if n.x.abs() > 0.5 {
|
||
(Vec3::Y, Vec3::Z)
|
||
} else {
|
||
(Vec3::X, Vec3::Z)
|
||
}
|
||
}
|
||
|
||
/// The patches (zones) of face `n` that belong to region `d` (a face, edge or corner direction
|
||
/// with components −1, 0 or 1): the zones a click on would turn to `d`.
|
||
pub(crate) fn cube_patches_on(n: Vec3, d: Vec3) -> Vec<(i32, i32)> {
|
||
let (e1, e2) = cube_face_axes(n);
|
||
let mut out = Vec::new();
|
||
for zi in -1..=1 {
|
||
for zj in -1..=1 {
|
||
if (n + e1 * f64::from(zi) + e2 * f64::from(zj)).dist(d) < 1e-9 {
|
||
out.push((zi, zj));
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Width of the edge/corner band of a view cube face, as a fraction of the face.
|
||
const CUBE_BAND: f64 = 0.2;
|
||
|
||
/// Which of the 3×3 zones of a projected cube face (corners in order −−, +−, ++, −+) the point
|
||
/// is over: (−1|0|1, −1|0|1).
|
||
pub(crate) fn cube_zone(pts: &[Pos2], p: Pos2) -> Option<(i32, i32)> {
|
||
let [c0, c1, _, c3] = pts else { return None };
|
||
let (ux, uy) = ((c1.x - c0.x) as f64, (c1.y - c0.y) as f64);
|
||
let (vx, vy) = ((c3.x - c0.x) as f64, (c3.y - c0.y) as f64);
|
||
let det = ux * vy - uy * vx;
|
||
if det.abs() < 1e-9 {
|
||
return None;
|
||
}
|
||
let (px, py) = ((p.x - c0.x) as f64, (p.y - c0.y) as f64);
|
||
let a = (px * vy - py * vx) / det;
|
||
let b = (ux * py - uy * px) / det;
|
||
if !(-1e-6..=1.0 + 1e-6).contains(&a) || !(-1e-6..=1.0 + 1e-6).contains(&b) {
|
||
return None;
|
||
}
|
||
let k = |t: f64| {
|
||
if t < CUBE_BAND {
|
||
-1
|
||
} else if t > 1.0 - CUBE_BAND {
|
||
1
|
||
} else {
|
||
0
|
||
}
|
||
};
|
||
Some((k(a), k(b)))
|
||
}
|
||
|
||
fn point_in_poly(p: Pos2, poly: &[Pos2]) -> bool {
|
||
let n = poly.len();
|
||
let mut inside = false;
|
||
for i in 0..n {
|
||
let (a, b) = (poly[i], poly[(i + 1) % n]);
|
||
if (a.y > p.y) != (b.y > p.y) {
|
||
let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x);
|
||
if p.x < x {
|
||
inside = !inside;
|
||
}
|
||
}
|
||
}
|
||
inside
|
||
}
|
||
|
||
/// 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 } => solvecraft_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) {
|
||
let t = Tokens::get();
|
||
let items: [(&str, &str); 10] = [
|
||
("orbit", "Orbit (drag; or Shift+middle / right drag)"),
|
||
("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 = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0));
|
||
let painter = ui.painter_at(rect);
|
||
painter.rect(bar, 6.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||
for (i, (icon, tip)) in items.iter().enumerate() {
|
||
let br = Rect::from_min_size(pos2(bar.left() + 5.0 + i as f32 * 30.0, bar.top() + 2.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 = egui::Area::new(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("VIEW").strong().size(11.5));
|
||
ui.checkbox(&mut app.ui.show_grid, "Grid");
|
||
ui.checkbox(&mut app.ui.ground_shadow, "Ground shadow");
|
||
ui.separator();
|
||
let s = &mut app.ui.agent_cursor;
|
||
let show = ui.checkbox(&mut s.show, "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("Speed");
|
||
for sp in crate::agent_cursor::Speed::ALL {
|
||
ui.selectable_value(&mut s.speed, sp, sp.label());
|
||
}
|
||
});
|
||
ui.checkbox(&mut s.follow_camera, "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::*;
|
||
|
||
#[test]
|
||
fn view_cube_regions_light_their_patches() {
|
||
let faces = [Vec3::X, -Vec3::X, Vec3::Y, -Vec3::Y, Vec3::Z, -Vec3::Z];
|
||
let mut regions = 0;
|
||
for x in -1i32..=1 {
|
||
for y in -1i32..=1 {
|
||
for z in -1i32..=1 {
|
||
let d = Vec3::new(f64::from(x), f64::from(y), f64::from(z));
|
||
let k = x.abs() + y.abs() + z.abs();
|
||
if k == 0 {
|
||
continue;
|
||
}
|
||
regions += 1;
|
||
let lit: Vec<(Vec3, (i32, i32))> = faces.iter().flat_map(|n| cube_patches_on(*n, d).into_iter().map(move |z| (*n, z))).collect();
|
||
// A face lights 1 patch, an edge 2, a corner 3, each on a different face.
|
||
assert_eq!(lit.len() as i32, k, "{d:?}: {lit:?}");
|
||
// Each lit patch is one a click turns to this same region.
|
||
for (n, (zi, zj)) in lit {
|
||
let (e1, e2) = cube_face_axes(n);
|
||
assert!((n + e1 * f64::from(zi) + e2 * f64::from(zj)).dist(d) < 1e-12);
|
||
assert!(n.dot(d) > 0.5, "a patch on a face away from {d:?}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert_eq!(regions, 26);
|
||
}
|
||
|
||
#[test]
|
||
fn view_cube_zones() {
|
||
// A square face 100 px wide, corners in order −−, +−, ++, −+ (screen y down).
|
||
let pts = [pos2(0.0, 100.0), pos2(100.0, 100.0), pos2(100.0, 0.0), pos2(0.0, 0.0)];
|
||
assert_eq!(cube_zone(&pts, pos2(50.0, 50.0)), Some((0, 0)));
|
||
assert_eq!(cube_zone(&pts, pos2(5.0, 95.0)), Some((-1, -1)));
|
||
assert_eq!(cube_zone(&pts, pos2(95.0, 50.0)), Some((1, 0)));
|
||
assert_eq!(cube_zone(&pts, pos2(50.0, 5.0)), Some((0, 1)));
|
||
assert_eq!(cube_zone(&pts, pos2(150.0, 50.0)), None);
|
||
}
|
||
}
|