M0: desktop app: toolbar, browser, timeline, GPU viewport, view cube, sketch tools, dialogs, control channel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 17:54:58 +00:00
1 parent 3714342737
commit 8a811f8eff
33 files changed
+7321 -32

No files matched your search

+821
View File
@@ -0,0 +1,821 @@
//! 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::theme::Tokens;
use crate::{SolveApp, icons};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NavMode {
Orbit,
Pan,
Zoom,
}
#[derive(Default)]
pub struct ViewportState {
pub rect: Option<Rect>,
pub gpu: Option<GpuTarget>,
slot: SceneSlot,
key: u64,
cpu: Option<(u64, egui::TextureHandle)>,
pub nav: Option<NavMode>,
pub hover: Option<Hit>,
pub mouse: Option<Pos2>,
pub build_ms: f64,
}
/// Something under the cursor.
#[derive(Clone, Debug, PartialEq)]
pub enum Hit {
SketchPoint { sketch: u64, id: String, at: Vec2 },
SketchCurve { sketch: u64, id: String },
Edge { body: String, index: usize, mid: Vec3 },
Profile { sketch: u64, index: usize },
Face { body: String, index: usize, point: Vec3 },
}
/// 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 b = solvecraft_engine::view::bounds(&app.session);
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.model.state();
st.sketches
.iter()
.filter(|s| app.session.active_sketch == Some(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);
serde_json::to_string(&app.session.selection).unwrap_or_default().hash(&mut h);
serde_json::to_string(&app.ui).unwrap_or_default().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);
if let Some(d) = &app.dialog {
d.highlight_key().hash(&mut h);
}
h.finish()
}
fn build_scene(app: &SolveApp) -> GpuScene {
let mut sc = GpuScene::default();
let s = &app.session;
let st = s.model.state();
if app.ui.show_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 { rgba(colors::GRID_MAJOR) } else { rgba(colors::GRID) };
let w = if maj { 1.0 } else { 0.7 };
sc.line((c + Vec3::new(t, -ext, 0.0)).to_f32(), (c + Vec3::new(t, ext, 0.0)).to_f32(), col, w, false);
sc.line((c + Vec3::new(-ext, t, 0.0)).to_f32(), (c + Vec3::new(ext, t, 0.0)).to_f32(), col, w, false);
}
}
if app.ui.show_origin {
let a = app.cam.half_height() * 0.3;
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
sc.line([0.0; 3], (d * a).to_f32(), rgba(col), 2.0, false);
}
}
let dialog_edges: Vec<Vec3> = app.dialog.as_ref().map(|d| d.edges()).unwrap_or_default();
for b in &st.bodies {
if app.ui.hidden_bodies.contains(&b.name) {
continue;
}
let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name));
let col = rgba(if selected { colors::BODY_SELECTED } else { colors::BODY });
let m = b.mesh();
for t in &m.triangles {
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(), n.to_f32(), col);
}
}
}
for (ei, e) in m.edges.iter().enumerate() {
if m.seams.get(ei).copied().unwrap_or(false) {
continue;
}
let sel = s.selection.iter().any(|x| matches!(x, Sel::Edge { body, index, .. } if *body == b.name && *index == ei))
|| dialog_edges.iter().any(|p| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) < 1e-6 * b.body.size().max(1.0) + 1e-6));
for w in e.windows(2) {
if sel {
sc.line(w[0].to_f32(), w[1].to_f32(), [30, 120, 230, 255], 3.5, true);
} else {
sc.line(w[0].to_f32(), w[1].to_f32(), rgba(colors::EDGE), 1.3, false);
}
}
}
}
let dialog_profiles = app.dialog.as_ref().map(|d| d.profiles()).unwrap_or_default();
for sid in visible_sketches(app) {
let Some(ss) = st.sketch(sid) else { continue };
let active = s.active_sketch == Some(sid);
for (pts, col, cons) in sketch_lines(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) {
for w in pts.windows(2) {
sc.line(w[0].to_f32(), w[1].to_f32(), rgba(col), if cons { 1.2 } else { 2.0 }, active);
}
}
// Selected profiles: outline.
for (pi, p) in ss.profiles.iter().enumerate() {
let picked = dialog_profiles.contains(&(sid, pi))
|| s.selection.iter().any(|x| matches!(x, Sel::Profile { sketch, index } if *sketch == sid && *index == pi));
if !picked {
continue;
}
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(ss.plane.to_world(*a).to_f32(), ss.plane.to_world(*b).to_f32(), rgba(colors::PROFILE), 4.0, true);
}
}
}
}
}
sc
}
/// Pick what is under `pos`, most specific first.
pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
let s = &app.session;
let st = s.model.state();
let mut hits = Vec::new();
let (o, d) = proj.ray(pos);
// Sketch points and curves of the active sketch.
if let Some(sid) = s.active_sketch
&& let Some(ss) = st.sketch(sid)
{
let mut best: Option<(f32, Hit)> = None;
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);
}
let mut bestc: Option<(f32, Hit)> = None;
for (i, c) in ss.sketch.curves.iter().enumerate() {
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]);
if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) {
bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: c.id.clone() }));
}
}
}
}
if let Some((_, h)) = bestc {
hits.push(h);
}
}
// Edges.
let mut beste: Option<(f32, Hit)> = None;
for b in &st.bodies {
if app.ui.hidden_bodies.contains(&b.name) {
continue;
}
let m = b.mesh();
for (ei, e) in m.edges.iter().enumerate() {
if m.seams.get(ei).copied().unwrap_or(false) {
continue;
}
let pts: Vec<Pos2> = e.iter().filter_map(|q| proj.to_screen(*q)).collect();
for w in pts.windows(2) {
let dd = seg_dist(pos, w[0], w[1]);
if dd < 6.0 && beste.as_ref().is_none_or(|(bd, _)| dd < *bd) {
beste = Some((dd, Hit::Edge { body: b.name.clone(), index: ei, mid: polyline_mid(e) }));
}
}
}
}
// Faces (nearest along the ray), used to reject edges hidden behind faces.
let mut bestf: Option<(f64, Hit)> = None;
for b in &st.bodies {
if app.ui.hidden_bodies.contains(&b.name) {
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 }));
}
}
if let Some((_, h)) = beste {
// An edge counts only when it is not hidden behind the first face along the ray.
let visible = match (&h, &bestf) {
(Hit::Edge { mid, .. }, Some((t, _))) => (*mid - o).dot(d) <= *t + scene_radius(app) * 0.05,
_ => true,
};
if visible {
hits.push(h);
}
}
// Profiles of visible sketches.
let mut bestp: Option<(f64, Hit)> = None;
for sid in visible_sketches(app) {
let Some(ss) = st.sketch(sid) else { continue };
let Some(w) = ss.plane.intersect_ray(o, d) else { continue };
let lp = ss.plane.to_local(w);
for (pi, p) in ss.profiles.iter().enumerate() {
if p.region.contains(lp) && bestp.as_ref().is_none_or(|(a, _)| p.area < *a) {
bestp = Some((p.area, Hit::Profile { sketch: sid, index: pi }));
}
}
}
if let Some((_, h)) = bestp {
hits.push(h);
}
if let Some((_, h)) = bestf {
hits.push(h);
}
hits
}
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.model.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);
let (minor, _) = grid_step(app.cam.half_height());
let step = minor / 10.0;
Some((Vec2::new((lp.x / step).round() * step, (lp.y / step).round() * step), None))
}
pub fn delete_selection(app: &mut SolveApp) {
let sel = app.session.selection.clone();
if sel.is_empty() {
return;
}
let mut sketch_ids: Vec<String> = Vec::new();
let mut features: Vec<u64> = Vec::new();
for x in &sel {
match x {
Sel::SketchCurve { id } | Sel::SketchPoint { id } => sketch_ids.push(id.clone()),
Sel::Feature { id } => features.push(*id),
_ => {}
}
}
if !sketch_ids.is_empty() && app.session.active_sketch.is_some() {
let _ = app.run("sketch.delete", json!({"entities": sketch_ids}));
}
if !features.is_empty() {
let _ = app.run("FusionDeleteCommand", json!({"features": features.iter().map(|f| f.to_string()).collect::<Vec<_>>()}));
}
let _ = app.run("select.clear", json!({}));
}
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(),
)
});
let inside = hover.is_some_and(|p| rect.contains(p));
app.viewport.mouse = hover.filter(|_| inside);
// ---- navigation ----
let h = rect.height().max(1.0) as f64;
if inside && scroll != 0.0 {
let f = (-scroll as f64 * 0.0015).exp();
let anchor = hover.map(|p| {
let proj = projection(app, rect);
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.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 && (mods.shift) || (secondary_down && !middle) {
Some(NavMode::Orbit)
} else if middle {
Some(NavMode::Pan)
} else if primary_down {
app.viewport.nav
} else {
None
};
match mode {
Some(NavMode::Orbit) => app.cam.orbit(dx, dy),
Some(NavMode::Pan) => app.cam.pan(dx, dy, h),
Some(NavMode::Zoom) => app.cam.zoom_at((dy * 0.01).exp(), None),
None => {}
}
}
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 = Color32::from_rgb(colors::BG_TOP.0, colors::BG_TOP.1, colors::BG_TOP.2);
let bot = Color32::from_rgb(colors::BG_BOTTOM.0, colors::BG_BOTTOM.1, colors::BG_BOTTOM.2);
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 ppp = ui.ctx().pixels_per_point();
let cb = ViewportCallback {
key: app.viewport.key,
slot: app.viewport.slot.clone(),
view_proj: proj.vp.to_f32(),
back: proj.cam.back().to_f32(),
size_px: [rect.width() * ppp, rect.height() * ppp],
};
painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb));
} else {
cpu_render(app, ui.ctx(), &painter, rect, &proj);
}
// ---- interaction ----
let hits = hover.filter(|_| inside).map(|p| pick(app, &proj, p)).unwrap_or_default();
app.viewport.hover = hits.first().cloned();
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(d) = app.dialog.as_mut() {
d.on_pick(&hits);
}
} else {
select(app, &hits, mods.shift || mods.command);
}
}
if resp.secondary_clicked() && delta == egui::Vec2::ZERO && app.tool.is_some() {
crate::tools::finish(app);
}
}
overlays(app, ui, &painter, &proj);
hover_highlight(app, &painter, &proj);
if let Some(tl) = app.tool.as_ref() {
crate::tools::preview(app, tl, &painter, &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, Color32::from_white_alpha(230));
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;
}
}
}
fn select(app: &mut SolveApp, hits: &[Hit], add: bool) {
let item = hits.first().map(|h| match h {
Hit::SketchPoint { id, .. } => json!({"type": "sketch_point", "id": id}),
Hit::SketchCurve { id, .. } => json!({"type": "sketch_curve", "id": id}),
Hit::Edge { body, index, mid } => json!({"type": "edge", "body": body, "index": index, "point": mid}),
Hit::Profile { sketch, index } => json!({"type": "profile", "sketch": sketch, "index": index}),
Hit::Face { body, .. } => json!({"type": "body", "name": body}),
});
match item {
Some(i) => {
let _ = app.run("select.set", json!({"items": [i], "add": add}));
}
None => {
let _ = app.run("select.clear", json!({}));
}
}
}
fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
let col = Color32::from_rgb(40, 140, 255);
let st = app.session.model.state();
match &app.viewport.hover {
Some(Hit::Edge { body, index, .. }) => {
if let Some(b) = st.body(body)
&& let Some(e) = b.mesh().edges.get(*index)
{
let pts: Vec<Pos2> = e.iter().filter_map(|q| proj.to_screen(*q)).collect();
painter.add(Shape::line(pts, Stroke::new(3.0, col)));
}
}
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));
}
}
Some(Hit::Profile { sketch, index }) if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) => {
if let Some(ss) = st.sketch(*sketch)
&& let Some(p) = ss.profiles.get(*index)
{
let pts: Vec<Pos2> = p.region.outer.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect();
painter.add(Shape::closed_line(pts, Stroke::new(2.0, Color32::from_rgb(255, 170, 60))));
}
}
_ => {}
}
}
/// Sketch points, dimension labels and constraint glyphs of the active sketch.
fn overlays(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj) {
let t = Tokens::get();
let st = app.session.model.state();
let Some(sid) = app.session.active_sketch else { return };
let Some(ss) = st.sketch(sid) else { return };
let sk = &ss.sketch;
for (i, p) in sk.points.iter().enumerate() {
let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) else { continue };
let det = ss.report.point_determined.get(i).copied().unwrap_or(false);
let c = if det { Color32::BLACK } else { Color32::from_rgb(30, 90, 200) };
if i == 0 {
painter.circle(sp, 3.5, Color32::from_rgb(240, 200, 60), Stroke::new(1.0, Color32::BLACK));
} else {
painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c);
}
}
let mut edit: Option<String> = None;
for c in &sk.constraints {
let Some(param) = &c.param else {
glyph(painter, proj, ss, &c.kind);
continue;
};
let expr = app.session.doc.param(param).map(|p| p.expr.clone()).unwrap_or_default();
let anchor = dim_anchor(sk, &c.kind);
let Some(a) = anchor.and_then(|a| proj.to_screen(ss.plane.to_world(a))) else { continue };
let text = if expr.chars().all(|ch| ch.is_ascii_digit() || ch == '.' || ch == ' ' || ch == 'm' || ch == 'd' || ch == 'e' || ch == 'g') {
expr.replace(" mm", "").replace(" deg", "°")
} else {
format!("{param}: {}", expr)
};
let galley = painter.layout_no_wrap(text, FontId::proportional(12.0), t.text);
let r = Rect::from_center_size(a, galley.size() + vec2(8.0, 4.0));
painter.rect(r, 2.0, Color32::from_white_alpha(235), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
painter.galley(r.min + vec2(4.0, 2.0), galley, t.text);
let resp = ui.interact(r, ui.id().with(("dim", param.as_str())), Sense::click());
if resp.double_clicked() || resp.clicked() && app.tool.is_none() {
edit = Some(param.clone());
}
}
if let Some(p) = edit {
app.dialog = Some(crate::dialogs::Dialog::edit_param(&app.session, &p));
}
// 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,
}
}
/// Where a dimension label goes (sketch coordinates).
fn dim_anchor(sk: &solvecraft_engine::sketch::Sketch, k: &ConstraintKind) -> Option<Vec2> {
use ConstraintKind::*;
match *k {
Length { l, .. } | PointLineDistance { l, .. } => {
let (a, b) = line_mid(sk, l)?;
let n = (b - a).perp().normalized().unwrap_or(Vec2::Y);
Some((a + b) * 0.5 + n * ((b - a).len() * 0.08 + 1.0))
}
Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => Some((sk.point(p)? + sk.point(q)?) * 0.5),
Radius { c, .. } | Diameter { c, .. } => {
let r = sk.radius(c)?;
Some(sk.center(c)? + Vec2::new(std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2) * r)
}
Angle { a, b, .. } => {
let (a0, a1) = line_mid(sk, a)?;
let (b0, b1) = line_mid(sk, b)?;
Some((a0 + a1 + b0 + b1) * 0.25)
}
_ => 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));
painter.rect_filled(r, 2.0, Color32::from_rgb(255, 255, 255));
painter.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_rgb(90, 150, 90)), egui::StrokeKind::Inside);
painter.text(r.center(), Align2::CENTER_CENTER, label, FontId::proportional(10.0), Color32::from_rgb(40, 110, 40));
}
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);
}
}
/// 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 = 32.0f64;
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 mut clicked: Option<StandardView> = None;
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)));
for i in order {
let (n, label, v) = faces[i];
if n.dot(b) <= 1e-3 {
continue;
}
let (e1, e2) = 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)
};
let corners = [n - e1 - e2, n + e1 - e2, n + e1 + e2, n - e1 + e2];
let pts: Vec<Pos2> = corners.iter().map(|p| to2(*p).0).collect();
let poly_hover = hover.is_some_and(|h| point_in_poly(h, &pts));
let shade = (0.75 + 0.25 * n.dot(b)) as f32;
let fill = if poly_hover { t.accent_soft } else { Color32::from_gray((236.0 * shade) as u8) };
painter.add(Shape::convex_polygon(pts.clone(), fill, Stroke::new(1.0, Color32::from_rgb(140, 148, 160))));
let center = to2(n).0;
if n.dot(b) > 0.35 {
painter.text(center, Align2::CENTER_CENTER, label, FontId::proportional(9.5), t.text);
}
if poly_hover && ui.input(|i| i.pointer.primary_clicked()) {
clicked = Some(v);
}
}
// 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 { Color32::WHITE }, t.accent);
if hr.on_hover_text("Home view").clicked() {
app.cam.set_view(StandardView::Iso);
app.fit_view();
}
if let Some(v) = clicked {
app.cam.set_view(v);
}
}
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).
fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let t = Tokens::get();
let items: [(&str, &str); 7] = [
("orbit", "Orbit (drag; or Shift+middle / right drag)"),
("pan", "Pan (drag; or middle drag)"),
("zoom", "Zoom (drag; or wheel)"),
("fit", "Fit"),
("home", "Home view"),
("perspective", "Perspective / orthographic"),
("settings", "Grid on/off"),
];
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, Color32::from_white_alpha(235), 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" => app.ui.show_grid,
_ => 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.fit_view(),
"home" => {
app.cam.set_view(StandardView::Iso);
app.fit_view();
}
"perspective" => app.ui.perspective = !app.ui.perspective,
_ => app.ui.show_grid = !app.ui.show_grid,
}
}
}
}