Fixes #15 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2245 lines
96 KiB
Rust
2245 lines
96 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};
|
|
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 axes shown, as segments: across the model where the axis passes it (a little
|
|
/// past it either side), at least the origin axes' length around the axis's origin: (name,
|
|
/// from, to).
|
|
pub fn construction_axis_segments(app: &SolveApp) -> Vec<(String, Vec3, Vec3)> {
|
|
let h = origin_size(app.cam.half_height()) * AXIS_LEN;
|
|
let bb = solvecraft_engine::view::bounds(&app.session);
|
|
solvecraft_engine::view::construction_axes(&app.session)
|
|
.into_iter()
|
|
.filter(|(_, n, _, _)| !app.ui.hidden_origin.contains(n))
|
|
.map(|(_, name, o, d)| {
|
|
let (mut lo, mut hi) = (-h, h);
|
|
if !bb.is_empty() {
|
|
for i in 0..8 {
|
|
let c = Vec3::new(
|
|
if i & 1 == 0 { bb.min.x } else { bb.max.x },
|
|
if i & 2 == 0 { bb.min.y } else { bb.max.y },
|
|
if i & 4 == 0 { bb.min.z } else { bb.max.z },
|
|
);
|
|
let t = (c - o).dot(d);
|
|
lo = lo.min(t - h * 0.3);
|
|
hi = hi.max(t + h * 0.3);
|
|
}
|
|
}
|
|
(name, o + d * lo, o + d * hi)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Construction points shown: (name, position).
|
|
pub fn construction_points(app: &SolveApp) -> Vec<(String, Vec3)> {
|
|
solvecraft_engine::view::construction_points(&app.session)
|
|
.into_iter()
|
|
.filter(|(_, n, _)| !app.ui.hidden_origin.contains(n))
|
|
.map(|(_, n, p)| (n, p))
|
|
.collect()
|
|
}
|
|
|
|
/// Construction planes drawn as squares around their origin: (name, normal, quad).
|
|
pub fn construction_quads(app: &SolveApp) -> Vec<(String, Vec3, [Vec3; 4])> {
|
|
let h = origin_size(app.cam.half_height()) * 1.2;
|
|
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() }));
|
|
}
|
|
}
|
|
}
|
|
// Construction axes, picked like the origin axes.
|
|
for (name, a, b) in construction_axis_segments(app) {
|
|
if let (Some(a), Some(b)) = (proj.to_screen(a), proj.to_screen(b)) {
|
|
let dd = seg_dist(pos, a, b);
|
|
if dd < 5.0 && besta.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
|
besta = Some((dd, Hit::Axis { name }));
|
|
}
|
|
}
|
|
}
|
|
hits.extend(besta.map(|x| x.1));
|
|
// Surfaces by depth: profiles (slightly preferred over a face they lie on), faces, planes.
|
|
let mut surf: Vec<(f64, Hit)> = Vec::new();
|
|
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;
|
|
}
|
|
}
|
|
// 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.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): a cube with chamfered corners. Its 14 targets are the 6 faces
|
|
/// (orthographic views) and the 8 corner facets (iso views); the edges between them are not
|
|
/// targets. Hover lights the face or facet under the pointer, a click turns the view to it, the
|
|
/// house goes home.
|
|
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 (r, u, b) = app.cam.basis();
|
|
let painter = ui.painter_at(rect);
|
|
let hover = ui.input(|i| i.pointer.hover_pos());
|
|
let targets = cube_targets();
|
|
let shapes = cube_projected(&targets, (r, u, b), c);
|
|
let hovered = hover.and_then(|h| cube_hit(&shapes, h));
|
|
// Back to front: the faces and facets turned toward the camera.
|
|
for (i, pts) in shapes.iter().rev() {
|
|
let tg = &targets[*i];
|
|
let n = tg.normal();
|
|
let shade = (0.75 + 0.25 * n.dot(b)) as f32;
|
|
let fill = if tg.corner { t.cube_face.gamma_multiply(shade * 0.92) } else { t.cube_face.gamma_multiply(shade) };
|
|
painter.add(Shape::convex_polygon(pts.clone(), fill.to_opaque(), Stroke::new(1.0, t.cube_edge)));
|
|
if hovered == Some(*i) {
|
|
painter.add(Shape::convex_polygon(pts.clone(), t.accent_soft, Stroke::new(1.0, t.accent)));
|
|
}
|
|
let centre = cube_centroid(pts);
|
|
crate::scenario::publish_handle(&format!("cube:{}", tg.name), centre);
|
|
if !tg.corner && n.dot(b) > 0.35 {
|
|
painter.text(centre, Align2::CENTER_CENTER, tg.name.to_uppercase(), FontId::proportional(9.5), Color32::from_rgb(40, 44, 52));
|
|
}
|
|
}
|
|
crate::scenario::publish_count("cube_highlights", f64::from(u8::from(hovered.is_some())));
|
|
if let Some(i) = hovered {
|
|
crate::scenario::publish_handle(&format!("cube_hover:{}", targets[i].name), hover.unwrap_or(c));
|
|
}
|
|
// Clicked: the view from the face's or facet's direction (target → eye).
|
|
let clicked = hovered.filter(|_| ui.input(|i| i.pointer.primary_clicked())).map(|i| targets[i].dir);
|
|
// Axis triad at the cube's corner.
|
|
let to2 = |p: Vec3| pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32);
|
|
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)] {
|
|
painter.line_segment([to2(o), to2(o + d * 2.6)], 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);
|
|
}
|
|
}
|
|
|
|
/// How far along each edge a view cube corner is cut off (the cube's faces are at ±1).
|
|
const CUBE_CHAMFER: f64 = 0.38;
|
|
|
|
/// A view cube target: a face or a corner facet, its view direction (target → eye) and outline
|
|
/// (cube units).
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct CubeTarget {
|
|
pub name: String,
|
|
pub dir: Vec3,
|
|
pub corner: bool,
|
|
pub outline: Vec<Vec3>,
|
|
}
|
|
|
|
impl CubeTarget {
|
|
fn normal(&self) -> Vec3 {
|
|
self.dir.normalized().unwrap_or(Vec3::Z)
|
|
}
|
|
}
|
|
|
|
/// The 14 targets: 6 octagonal faces and 8 triangular corner facets.
|
|
pub(crate) fn cube_targets() -> Vec<CubeTarget> {
|
|
let k = CUBE_CHAMFER;
|
|
let mut out = Vec::new();
|
|
for (n, name) in [
|
|
(Vec3::new(0.0, -1.0, 0.0), "front"),
|
|
(Vec3::new(0.0, 1.0, 0.0), "back"),
|
|
(Vec3::new(1.0, 0.0, 0.0), "right"),
|
|
(Vec3::new(-1.0, 0.0, 0.0), "left"),
|
|
(Vec3::Z, "top"),
|
|
(Vec3::new(0.0, 0.0, -1.0), "bottom"),
|
|
] {
|
|
let (e1, e2) = cube_face_axes(n);
|
|
let uv =
|
|
[(-1.0 + k, -1.0), (1.0 - k, -1.0), (1.0, -1.0 + k), (1.0, 1.0 - k), (1.0 - k, 1.0), (-1.0 + k, 1.0), (-1.0, 1.0 - k), (-1.0, -1.0 + k)];
|
|
out.push(CubeTarget { name: name.into(), dir: n, corner: false, outline: uv.iter().map(|(a, b2)| n + e1 * *a + e2 * *b2).collect() });
|
|
}
|
|
for sz in [1.0, -1.0] {
|
|
for sy in [-1.0, 1.0] {
|
|
for sx in [1.0, -1.0] {
|
|
let corner = Vec3::new(sx, sy, sz);
|
|
let name = format!(
|
|
"{}-{}-{}",
|
|
if sz > 0.0 { "top" } else { "bottom" },
|
|
if sy < 0.0 { "front" } else { "back" },
|
|
if sx > 0.0 { "right" } else { "left" }
|
|
);
|
|
let outline = vec![corner - Vec3::X * (sx * k), corner - Vec3::Y * (sy * k), corner - Vec3::Z * (sz * k)];
|
|
out.push(CubeTarget { name, dir: corner, corner: true, outline });
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The targets turned toward the camera (basis `rub`), front first, as screen polygons around
|
|
/// the cube's centre `c`.
|
|
pub(crate) fn cube_projected(targets: &[CubeTarget], rub: (Vec3, Vec3, Vec3), c: Pos2) -> Vec<(usize, Vec<Pos2>)> {
|
|
let (r, u, b) = rub;
|
|
let to2 = |p: Vec3| pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32);
|
|
let mut v: Vec<(usize, f64, Vec<Pos2>)> = targets
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, tg)| tg.normal().dot(b) > 1e-3)
|
|
.map(|(i, tg)| (i, tg.normal().dot(b), tg.outline.iter().map(|p| to2(*p)).collect()))
|
|
.collect();
|
|
v.sort_by(|a, b2| b2.1.total_cmp(&a.1));
|
|
v.into_iter().map(|(i, _, p)| (i, p)).collect()
|
|
}
|
|
|
|
/// The target under `p`. The faces and facets tile the cube, so a point on an edge between two
|
|
/// of them (where neither polygon claims it) goes to the nearer one, within a few pixels.
|
|
pub(crate) fn cube_hit(shapes: &[(usize, Vec<Pos2>)], p: Pos2) -> Option<usize> {
|
|
if let Some((i, _)) = shapes.iter().find(|(_, pts)| point_in_poly(p, pts)) {
|
|
return Some(*i);
|
|
}
|
|
shapes.iter().map(|(i, pts)| (*i, poly_distance(p, pts))).filter(|(_, d)| *d <= 3.0).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
|
|
}
|
|
|
|
fn poly_distance(p: Pos2, pts: &[Pos2]) -> f32 {
|
|
(0..pts.len())
|
|
.map(|i| {
|
|
let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
|
|
let ab = b - a;
|
|
let t = ((p - a).dot(ab) / ab.length_sq().max(1e-6)).clamp(0.0, 1.0);
|
|
p.distance(a + ab * t)
|
|
})
|
|
.fold(f32::INFINITY, f32::min)
|
|
}
|
|
|
|
fn cube_centroid(pts: &[Pos2]) -> Pos2 {
|
|
let n = pts.len().max(1) as f32;
|
|
pos2(pts.iter().map(|p| p.x).sum::<f32>() / n, pts.iter().map(|p| p.y).sum::<f32>() / n)
|
|
}
|
|
|
|
/// The in-face axes of a view cube face.
|
|
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)
|
|
}
|
|
}
|
|
|
|
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::*;
|
|
|
|
/// A camera basis looking from `dir` (target → eye), as the view cube draws with.
|
|
fn basis_from(dir: Vec3) -> (Vec3, Vec3, Vec3) {
|
|
let cam = solvecraft_engine::render::Camera::default().looking_from(dir);
|
|
cam.basis()
|
|
}
|
|
|
|
#[test]
|
|
fn view_cube_has_14_targets_each_hit_where_drawn() {
|
|
let targets = cube_targets();
|
|
assert_eq!(targets.len(), 14);
|
|
assert_eq!(targets.iter().filter(|t| t.corner).count(), 8);
|
|
let c = pos2(100.0, 100.0);
|
|
for (i, tg) in targets.iter().enumerate() {
|
|
// Seen from a little off its own direction, its middle hits it.
|
|
let shapes = cube_projected(&targets, basis_from(tg.dir + Vec3::new(0.11, 0.07, 0.05)), c);
|
|
let pts = &shapes.iter().find(|(j, _)| *j == i).expect("visible").1;
|
|
assert_eq!(cube_hit(&shapes, cube_centroid(pts)), Some(i), "{}", tg.name);
|
|
}
|
|
// From an iso view: 3 faces and the 4 facets turned toward it.
|
|
let shapes = cube_projected(&targets, basis_from(Vec3::new(1.0, -1.0, 1.0)), c);
|
|
assert_eq!(shapes.len(), 3 + 4);
|
|
assert_eq!(cube_hit(&shapes, pos2(400.0, 400.0)), None);
|
|
}
|
|
|
|
#[test]
|
|
fn view_cube_edges_are_not_targets() {
|
|
let targets = cube_targets();
|
|
let c = pos2(100.0, 100.0);
|
|
let shapes = cube_projected(&targets, basis_from(Vec3::new(1.0, -1.2, 0.9)), c);
|
|
let (r, u, _) = basis_from(Vec3::new(1.0, -1.2, 0.9));
|
|
let to2 = |p: Vec3| pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32);
|
|
let name = |i: Option<usize>| i.map(|i| targets[i].name.clone()).unwrap_or_default();
|
|
// On the top-front edge, its middle and near its ends: a neighbouring face (or facet).
|
|
for x in [-0.5, 0.0, 0.5] {
|
|
let hit = name(cube_hit(&shapes, to2(Vec3::new(x, -1.0, 1.0))));
|
|
assert!(hit == "top" || hit == "front", "edge point {x}: {hit}");
|
|
}
|
|
let end = name(cube_hit(&shapes, to2(Vec3::new(0.9, -1.0, 1.0))));
|
|
assert!(["top", "front", "top-front-right"].contains(&end.as_str()), "{end}");
|
|
// Every direction a click turns to is a face's or a corner's: none along an edge.
|
|
for tg in &targets {
|
|
let zeros = [tg.dir.x, tg.dir.y, tg.dir.z].iter().filter(|v| v.abs() < 1e-9).count();
|
|
assert!(zeros == 2 || zeros == 0, "{}: {:?}", tg.name, tg.dir);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn construction_axes_span_the_model_and_points_show() {
|
|
let mut s = solvecraft_engine::Session::default();
|
|
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
|
s.execute("construct.axis.two_points", &json!({"a": {"vertex": [0, 0, 20]}, "b": {"vertex": [40, 30, 20]}})).unwrap();
|
|
s.execute("construct.point.vertex", &json!({"point": {"vertex": [40, 0, 20]}})).unwrap();
|
|
let mut app = SolveApp::new(s, crate::Services::default());
|
|
let axes = construction_axis_segments(&app);
|
|
assert_eq!(axes.len(), 1);
|
|
let (name, a, b) = axes[0].clone();
|
|
// The segment runs through both points and past them.
|
|
let d = (b - a).normalized().unwrap();
|
|
for p in [Vec3::new(0.0, 0.0, 20.0), Vec3::new(40.0, 30.0, 20.0)] {
|
|
let t = (p - a).dot(d);
|
|
assert!(t > 0.0 && t < a.dist(b) && (a + d * t).dist(p) < 1e-6, "{p:?} not on {a:?}..{b:?}");
|
|
}
|
|
assert_eq!(construction_points(&app).len(), 1);
|
|
// It is an axis for dialogs (pattern directions, revolve axes), and hiding it hides it.
|
|
let (o, dir) = crate::dialogs::axis_of(&app, &Sel::Axis { name: name.clone() }).unwrap();
|
|
assert!(o.dist(Vec3::new(0.0, 0.0, 20.0)) < 1e-6 && dir.dist(d) < 1e-6);
|
|
app.ui.hidden_origin.push(name);
|
|
assert!(construction_axis_segments(&app).is_empty());
|
|
}
|
|
}
|