M0: desktop app: toolbar, browser, timeline, GPU viewport, view cube, sketch tools, dialogs, control channel
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3714342737
commit
8a811f8eff
33 files changed
+7321
-32
No files matched your search
Generated
+3378
-16
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,20 @@
|
||||
[package]
|
||||
name = "solvecraft"
|
||||
description = "SolveCraft desktop app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
default-run = "solvecraft"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solvecraft-engine = { workspace = true }
|
||||
solvecraft-ui-egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! Agents and tests drive the app through it (adapted from CADCraft, MIT OR Apache-2.0).
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_ui_egui::ControlRequest;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("solvecraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("solvecraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
-1
@@ -1 +1,109 @@
|
||||
fn main() {}
|
||||
//! SolveCraft desktop app.
|
||||
//!
|
||||
//! Usage: `solvecraft [--control <port>] [--sample] [design.solvecraft]`
|
||||
//!
|
||||
//! `--control <port>` (or `SOLVECRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
|
||||
//! server: `{"id":1,"method":"engine.execute","params":{"command":"PrimitiveBox","params":{…}}}`.
|
||||
//! See `solvecraft_ui_egui::control` and docs/control-protocol.md.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod control_server;
|
||||
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_ui_egui::{Services, SolveApp};
|
||||
|
||||
struct App(SolveApp);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.0.logic(ctx);
|
||||
if self.0.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
}
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("SolveCraft design", &["solvecraft"])
|
||||
.add_filter("All files", &["*"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
|
||||
rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
const MSAA: u16 = 4;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let mut control_port: Option<u16> = std::env::var("SOLVECRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--sample" => sample = true,
|
||||
"--version" | "-V" => {
|
||||
println!("solvecraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("usage: solvecraft [--control PORT] [--sample] [design.solvecraft]");
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("SolveCraft")
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_app_id("ai.storyteller.solvecraft"),
|
||||
depth_buffer: DEPTH_BITS,
|
||||
multisampling: MSAA,
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
"SolveCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = SolveApp::new(Session::default(), services());
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs, DEPTH_BITS, u32::from(MSAA));
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
if sample {
|
||||
if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) {
|
||||
app.set_status(format!("sample: {e}"), true);
|
||||
}
|
||||
app.session.doc_mut().name = "Sample Plate".into();
|
||||
app.session.mark_saved();
|
||||
app.session.undo.clear();
|
||||
}
|
||||
for f in &files {
|
||||
app.open_path(f);
|
||||
}
|
||||
Ok(Box::new(App(app)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ impl ModelBody {
|
||||
self.mesh
|
||||
.get_or_init(|| {
|
||||
let tol = (self.body.size() * 1e-3).max(1e-3);
|
||||
Arc::new(self.body.tessellate(tol).unwrap_or_default())
|
||||
Arc::new(self.body.display_mesh(tol).unwrap_or_default())
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
@@ -303,7 +303,9 @@ fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
|
||||
&& (fp - f.centroid).dot(n).abs() < tol * 10.0
|
||||
&& b.body.tessellate(tol).map(|m| face_contains(&m, f.index, fp, tol * 10.0)).unwrap_or(false)
|
||||
{
|
||||
found = Plane::from_normal(f.centroid + n * (fp - f.centroid).dot(n), n);
|
||||
// Sketch origin: the model origin projected onto the face plane.
|
||||
let d = (f.centroid + n * (fp - f.centroid).dot(n)).dot(n);
|
||||
found = Plane::from_normal(n * d, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod params;
|
||||
pub mod sample;
|
||||
pub mod view;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! A sample design (used by `solvecraft --sample` and demos): a parametric plate with rounded
|
||||
//! corners, two holes and a boss.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn script() -> Value {
|
||||
json!({"commands": [
|
||||
{"command": "ChangeParameterCommand", "params": {"name": "width", "expression": "80 mm", "comment": "plate width"}},
|
||||
{"command": "ChangeParameterCommand", "params": {"name": "depth", "expression": "50 mm"}},
|
||||
{"command": "ChangeParameterCommand", "params": {"name": "thickness", "expression": "8 mm"}},
|
||||
{"command": "SketchCreate", "params": {"plane": "XY", "name": "Base"}},
|
||||
{"command": "ShapeRectangleTwoPoint", "params": {"p0": [0, 0], "p1": [80, 50]}},
|
||||
{"command": "ConstraintCoincident", "params": {"a": "p1", "b": "origin"}},
|
||||
{"command": "SketchDimension", "params": {"entities": ["l1"], "value": "width"}},
|
||||
{"command": "SketchDimension", "params": {"entities": ["l2"], "value": "depth"}},
|
||||
{"command": "SketchStop", "params": {}},
|
||||
{"command": "Extrude", "params": {"distance": "thickness", "body_name": "Plate"}},
|
||||
{"command": "FusionFilletEdgesCommand", "params": {"edges": [[0, 0, 4], [80, 0, 4], [80, 50, 4], [0, 50, 4]], "radius": "6 mm"}},
|
||||
{"command": "SketchCreate", "params": {"plane": "XY", "name": "Holes"}},
|
||||
{"command": "CircleCenterRadius", "params": {"center": [12, 25], "diameter": 8}},
|
||||
{"command": "CircleCenterRadius", "params": {"center": [68, 25], "diameter": 8}},
|
||||
{"command": "SketchStop", "params": {}},
|
||||
{"command": "Extrude", "params": {"distance": "thickness", "operation": "cut", "name": "Holes"}},
|
||||
{"command": "SketchCreate", "params": {"plane": {"face": [40, 25, 8]}, "name": "Boss"}},
|
||||
{"command": "CircleCenterRadius", "params": {"center": [40, 25], "diameter": 24}},
|
||||
{"command": "SketchStop", "params": {}},
|
||||
{"command": "Extrude", "params": {"distance": 14, "operation": "join", "name": "Boss"}},
|
||||
{"command": "SketchCreate", "params": {"plane": {"face": [40, 37, 22]}, "name": "Bore"}},
|
||||
{"command": "CircleCenterRadius", "params": {"center": [40, 25], "diameter": 12}},
|
||||
{"command": "SketchStop", "params": {}},
|
||||
{"command": "Extrude", "params": {"distance": 22, "direction": "negative", "operation": "cut", "name": "Bore"}},
|
||||
]})
|
||||
}
|
||||
@@ -299,3 +299,16 @@ fn registry_is_consistent() {
|
||||
let s = Session::default();
|
||||
assert!(!find_command("DrawPolyline").unwrap().info(&s).enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_design_builds() {
|
||||
let mut s = Session::default();
|
||||
s.run_script(&crate::sample::script()).unwrap();
|
||||
let m = run(&mut s, "MeasureCommand", json!({}));
|
||||
assert_eq!(m["body_count"], 1, "{m}");
|
||||
let plate = 80.0 * 50.0 * 8.0 - 4.0 * (36.0 - PI * 9.0) * 8.0 - 2.0 * PI * 16.0 * 8.0;
|
||||
let boss = PI * 144.0 * 14.0;
|
||||
let bore = PI * 36.0 * 22.0;
|
||||
let v = m["total"]["volume_mm3"].as_f64().unwrap();
|
||||
assert!(rel(v, plate + boss - bore) < 1e-3, "{v} vs {}", plate + boss - bore);
|
||||
}
|
||||
@@ -106,7 +106,10 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
|
||||
let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name));
|
||||
let mesh = body.mesh();
|
||||
sc.meshes.push(SceneMesh { mesh: Arc::clone(&mesh), color: if selected { colors::BODY_SELECTED } else { colors::BODY } });
|
||||
for e in &mesh.edges {
|
||||
for (i, e) in mesh.edges.iter().enumerate() {
|
||||
if mesh.seams.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
sc.lines.push(SceneLine { points: e.clone(), color: colors::EDGE, width: 1.2, on_top: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ pub struct Mesh {
|
||||
pub tri_face: Vec<u32>,
|
||||
/// B-rep edges as polylines.
|
||||
pub edges: Vec<Vec<Vec3>>,
|
||||
/// Per edge: a seam between two pieces of the same smooth surface (not drawn).
|
||||
#[serde(default)]
|
||||
pub seams: Vec<bool>,
|
||||
}
|
||||
|
||||
/// Mass properties of a closed mesh.
|
||||
|
||||
@@ -6,7 +6,7 @@ fn cube_mesh(s: f64) -> Mesh {
|
||||
let triangles =
|
||||
vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]];
|
||||
let n = triangles.len();
|
||||
Mesh { normals: vec![Vec3::Z; 8], positions, triangles, tri_face: vec![0; n], edges: vec![] }
|
||||
Mesh { normals: vec![Vec3::Z; 8], positions, triangles, tri_face: vec![0; n], edges: vec![], seams: vec![] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -153,6 +153,13 @@ impl Body {
|
||||
})
|
||||
}
|
||||
|
||||
/// Display mesh: like [`Body::tessellate`], with seam edges flagged.
|
||||
pub fn display_mesh(&self, tol: f64) -> Result<Mesh> {
|
||||
let mut m = self.tessellate(tol)?;
|
||||
m.seams = crate::topo::seam_flags(self, &m);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Edges with polylines (index = position in [`Body::tessellate`]'s `edges`).
|
||||
pub fn edges(&self, tol: f64) -> Result<Vec<EdgeInfo>> {
|
||||
let m = self.tessellate(tol)?;
|
||||
|
||||
@@ -26,7 +26,7 @@ pub use build::{box_solid, cylinder, extrude, revolve, sphere, torus};
|
||||
pub use measure::{BodyMeasure, measure};
|
||||
pub use ops::{BoolOp, boolean, transform};
|
||||
pub use step::step_export;
|
||||
pub use topo::{TopoCounts, merged_topology};
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum KernelError {
|
||||
|
||||
@@ -138,6 +138,54 @@ fn find(p: &mut [usize], mut i: usize) -> usize {
|
||||
i
|
||||
}
|
||||
|
||||
/// Per edge (in `Body::edges` order): is it a seam between two pieces of the same surface?
|
||||
pub fn seam_flags(b: &Body, mesh: &Mesh) -> Vec<bool> {
|
||||
let solid = &*b.solid;
|
||||
let nf = solid.face_iter().count();
|
||||
let tol = (b.size() * 1e-4).max(1e-6);
|
||||
let surfs = classify_faces(mesh, nf, tol);
|
||||
let mut faces_of: HashMap<String, Vec<usize>> = HashMap::new();
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
for (fi, f) in solid.face_iter().enumerate() {
|
||||
for e in f.edge_iter() {
|
||||
let k = format!("{:?}", e.id());
|
||||
let v = faces_of.entry(k.clone()).or_insert_with(|| {
|
||||
order.push(k);
|
||||
Vec::new()
|
||||
});
|
||||
if !v.contains(&fi) {
|
||||
v.push(fi);
|
||||
}
|
||||
}
|
||||
}
|
||||
order
|
||||
.iter()
|
||||
.map(|k| match faces_of.get(k).map(Vec::as_slice) {
|
||||
Some([a, b2]) => match (surfs.get(*a), surfs.get(*b2)) {
|
||||
(Some(sa), Some(sb)) => !matches!(sa, Surf::Plane { .. }) && sa.same(sb, tol * 10.0),
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn classify_faces(mesh: &Mesh, nf: usize, tol: f64) -> Vec<Surf> {
|
||||
let mut pts: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||||
let mut nrm: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||||
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
for &k in t {
|
||||
if let (Some(p), Some(n), Some(pv), Some(nv)) =
|
||||
(mesh.positions.get(k as usize), mesh.normals.get(k as usize), pts.get_mut(*f as usize), nrm.get_mut(*f as usize))
|
||||
{
|
||||
pv.push(*p);
|
||||
nv.push(*n);
|
||||
}
|
||||
}
|
||||
}
|
||||
(0..nf).map(|i| classify(pts.get(i).map(Vec::as_slice).unwrap_or(&[]), nrm.get(i).map(Vec::as_slice).unwrap_or(&[]), tol * 10.0, i)).collect()
|
||||
}
|
||||
|
||||
/// Merged face/edge/vertex counts (see module docs). `mesh` must be `b.tessellate(..)`.
|
||||
pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
let solid = &*b.solid;
|
||||
|
||||
@@ -219,6 +219,7 @@ mod tests {
|
||||
triangles: vec![[0, 1, 2]],
|
||||
tri_face: vec![0],
|
||||
edges: vec![],
|
||||
seams: vec![],
|
||||
};
|
||||
let scene =
|
||||
Scene { meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(200, 30, 30) }], lines: vec![], background: None, radius: 20.0 };
|
||||
|
||||
@@ -295,8 +295,9 @@ pub struct Sketch {
|
||||
pub points: Vec<SPoint>,
|
||||
pub curves: Vec<Curve>,
|
||||
pub constraints: Vec<Constraint>,
|
||||
/// Next number per id prefix (`l` → l1, l2…).
|
||||
#[serde(default)]
|
||||
next_id: u64,
|
||||
counters: std::collections::BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
impl Default for Sketch {
|
||||
@@ -307,14 +308,22 @@ impl Default for Sketch {
|
||||
|
||||
impl Sketch {
|
||||
pub fn new() -> Self {
|
||||
Sketch { points: vec![SPoint { id: "origin".into(), pos: Vec2::ZERO, fixed: true }], curves: Vec::new(), constraints: Vec::new(), next_id: 1 }
|
||||
Sketch {
|
||||
points: vec![SPoint { id: "origin".into(), pos: Vec2::ZERO, fixed: true }],
|
||||
curves: Vec::new(),
|
||||
constraints: Vec::new(),
|
||||
counters: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fresh(&mut self, prefix: &str) -> String {
|
||||
let n = self.counters.entry(prefix.to_string()).or_insert(0);
|
||||
loop {
|
||||
let id = format!("{prefix}{}", self.next_id);
|
||||
self.next_id += 1;
|
||||
if !self.id_taken(&id) {
|
||||
*n += 1;
|
||||
let id = format!("{prefix}{n}");
|
||||
let taken =
|
||||
self.points.iter().any(|p| p.id == id) || self.curves.iter().any(|c| c.id == id) || self.constraints.iter().any(|c| c.id == id);
|
||||
if !taken {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
[package]
|
||||
name = "solvecraft-ui-egui"
|
||||
description = "SolveCraft user interface (egui + wgpu): the swappable front end"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solvecraft-engine = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
egui-wgpu = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
image = { workspace = true }
|
||||
@@ -0,0 +1,116 @@
|
||||
//! The browser (left): document settings, origin, bodies and sketches.
|
||||
|
||||
use egui::{Color32, RichText, Sense, vec2};
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::Sel;
|
||||
use solvecraft_engine::doc::FeatureKind;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{SolveApp, icons};
|
||||
|
||||
fn row(ui: &mut egui::Ui, icon: &str, label: &str, color: Option<Color32>, selected: bool) -> egui::Response {
|
||||
let t = Tokens::get();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::click());
|
||||
if selected {
|
||||
ui.painter().rect_filled(r, 2.0, t.accent_soft);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 2.0, t.hover);
|
||||
}
|
||||
let ir = egui::Rect::from_min_size(r.min + vec2(2.0, 2.0), vec2(16.0, 16.0));
|
||||
icons::paint(ui.painter(), ir, icon, t.icon, t.icon_fill, t.accent);
|
||||
ui.painter().text(r.min + vec2(22.0, 10.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), color.unwrap_or(t.text));
|
||||
resp
|
||||
}
|
||||
|
||||
fn eye(ui: &mut egui::Ui, visible: bool) -> bool {
|
||||
let t = Tokens::get();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
|
||||
icons::paint(ui.painter(), r, "eye", if visible { t.icon } else { t.border }, t.icon_fill, t.accent);
|
||||
resp.on_hover_text(if visible { "Hide" } else { "Show" }).clicked()
|
||||
}
|
||||
|
||||
pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
egui::Panel::left("sc_browser")
|
||||
.exact_size(230.0)
|
||||
.resizable(false)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 6)))
|
||||
.show(ui, |ui| {
|
||||
ui.label(RichText::new("BROWSER").size(11.0).strong().color(t.text_dim));
|
||||
ui.add_space(4.0);
|
||||
let st = app.session.model.state();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
|
||||
icons::paint(ui.painter(), r, "box", t.icon, t.icon_fill, t.accent);
|
||||
ui.label(RichText::new(&app.session.doc.name).strong());
|
||||
});
|
||||
ui.indent("doc", |ui| {
|
||||
egui::CollapsingHeader::new("Document Settings").default_open(false).show(ui, |ui| {
|
||||
row(ui, "settings", &format!("Units: {}", app.session.doc.units), None, false);
|
||||
let n = app.session.doc.params.len();
|
||||
if row(ui, "params", &format!("Parameters ({n})"), None, false).clicked() {
|
||||
app.start("ChangeParameterCommand");
|
||||
}
|
||||
});
|
||||
egui::CollapsingHeader::new("Origin").default_open(false).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if eye(ui, app.ui.show_origin) {
|
||||
app.ui.show_origin = !app.ui.show_origin;
|
||||
}
|
||||
ui.label("O, X, Y, Z");
|
||||
});
|
||||
for p in ["XY", "XZ", "YZ"] {
|
||||
row(ui, "plane", p, None, false);
|
||||
}
|
||||
});
|
||||
egui::CollapsingHeader::new(format!("Bodies ({})", st.bodies.len())).default_open(true).show(ui, |ui| {
|
||||
for b in &st.bodies {
|
||||
let visible = !app.ui.hidden_bodies.contains(&b.name);
|
||||
let selected = app.session.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name));
|
||||
ui.horizontal(|ui| {
|
||||
if eye(ui, visible) {
|
||||
if visible {
|
||||
app.ui.hidden_bodies.push(b.name.clone());
|
||||
} else {
|
||||
app.ui.hidden_bodies.retain(|n| n != &b.name);
|
||||
}
|
||||
}
|
||||
if row(ui, "body", &b.name, None, selected).clicked() {
|
||||
let _ = app.run("select.set", json!({"items": [{"type": "body", "name": b.name}]}));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let sketches: Vec<(u64, String)> = app
|
||||
.session
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.filter(|f| matches!(f.kind, FeatureKind::Sketch { .. }))
|
||||
.map(|f| (f.id, f.name.clone()))
|
||||
.collect();
|
||||
egui::CollapsingHeader::new(format!("Sketches ({})", sketches.len())).default_open(true).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if eye(ui, app.ui.show_sketches) {
|
||||
app.ui.show_sketches = !app.ui.show_sketches;
|
||||
}
|
||||
ui.label(RichText::new("show unused sketches").size(11.0).color(t.text_dim));
|
||||
});
|
||||
for (id, name) in sketches {
|
||||
let active = app.session.active_sketch == Some(id);
|
||||
let r = if active {
|
||||
row(ui, "sketch", &format!("{name} (editing)"), Some(t.sketch_accent), true)
|
||||
} else {
|
||||
row(ui, "sketch", &name, None, false)
|
||||
};
|
||||
if r.double_clicked() {
|
||||
let _ = app.run("SketchActivate", json!({"sketch": id}));
|
||||
}
|
||||
r.on_hover_text("Double-click to edit");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Programmatic control of the running app (agents, tests).
|
||||
//!
|
||||
//! Methods (JSON lines over the host's transport, `{"id", "method", "params"}`):
|
||||
//! - `engine.execute {command, params}`: run any command with JSON parameters (no dialogs)
|
||||
//! - `engine.script {commands: [...]}`: run a command script
|
||||
//! - `engine.commands`: every command with tab, panel, params and enablement
|
||||
//! - `document.inspect {measure?}`: the design (parameters, timeline, bodies, sketches)
|
||||
//! - `ui.inspect`: UI state, viewport rect, camera, tool and dialog
|
||||
//! - `ui.set {...UiState fields}`; `ui.view {view: front|back|top|bottom|left|right|iso|home|fit}`
|
||||
//! - `ui.start {command}`: like clicking the toolbar button (starts tools/dialogs)
|
||||
//! - `ui.click {x, y, button?, shift?}`, `ui.move {x, y}`, `ui.scroll {x, y, delta}`: real
|
||||
//! pointer input in screen points; `ui.key {key, cmd?, shift?}`, `ui.text {text}`
|
||||
//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path, width?, height?}`: headless
|
||||
//! CPU render of the model with the current camera (no window needed)
|
||||
//! - `ui.resize {width, height}`, `app.quit`
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::render::{StandardView, render_png};
|
||||
|
||||
use crate::SolveApp;
|
||||
|
||||
pub type ControlResponse = Value;
|
||||
|
||||
pub struct ControlRequest {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
pub reply: Sender<ControlResponse>,
|
||||
}
|
||||
|
||||
impl ControlRequest {
|
||||
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
(Self { method: method.into(), params, reply: tx }, rx)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Outcome {
|
||||
Done(Value),
|
||||
Screenshot { path: Option<String> },
|
||||
}
|
||||
|
||||
fn ok(v: Value) -> Outcome {
|
||||
Outcome::Done(json!({"ok": true, "result": v}))
|
||||
}
|
||||
fn err(e: impl std::fmt::Display) -> Outcome {
|
||||
Outcome::Done(json!({"ok": false, "error": e.to_string()}))
|
||||
}
|
||||
fn wrap(r: Result<Value, String>) -> Outcome {
|
||||
match r {
|
||||
Ok(v) => ok(v),
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn key_from(name: &str) -> Option<egui::Key> {
|
||||
egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() {
|
||||
"enter" | "return" => Some(egui::Key::Enter),
|
||||
"esc" | "escape" => Some(egui::Key::Escape),
|
||||
"delete" => Some(egui::Key::Delete),
|
||||
"space" => Some(egui::Key::Space),
|
||||
"tab" => Some(egui::Key::Tab),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||||
let p = &req.params;
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
|
||||
let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
match req.method.as_str() {
|
||||
"engine.execute" | "command" => {
|
||||
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
|
||||
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
|
||||
let r = app.run(id, params);
|
||||
if r.is_ok() && app.session.active_sketch.is_none() && app.session.model.state().bodies.len() == 1 && app.session.undo.len() <= 2 {
|
||||
app.fit_view();
|
||||
}
|
||||
wrap(r)
|
||||
}
|
||||
"engine.script" => wrap(app.session.run_script(p).map(|v| json!(v)).map_err(|e| e.to_string())),
|
||||
"engine.commands" => wrap(app.session.execute("engine.commands", &json!({})).map_err(|e| e.to_string())),
|
||||
"document.inspect" => wrap(app.session.execute("document.inspect", p).map_err(|e| e.to_string())),
|
||||
"ui.inspect" => ok(json!({
|
||||
"ui": serde_json::to_value(&app.ui).unwrap_or_default(),
|
||||
"viewport": app.viewport.rect.map(|r| json!([r.left(), r.top(), r.width(), r.height()])),
|
||||
"camera": app.cam,
|
||||
"tool": app.tool.as_ref().map(|t| t.cmd),
|
||||
"dialog": app.dialog.as_ref().map(|d| format!("{d:?}")),
|
||||
"hover": app.viewport.hover.as_ref().map(|h| format!("{h:?}")),
|
||||
"renderer": if app.viewport.gpu.is_some() { "gpu" } else { "cpu" },
|
||||
"frame_ms": app.frame_ms,
|
||||
"build_ms": app.viewport.build_ms,
|
||||
})),
|
||||
"ui.set" => {
|
||||
let mut cur = serde_json::to_value(&app.ui).unwrap_or(json!({}));
|
||||
if let (Some(o), Some(src)) = (cur.as_object_mut(), p.as_object()) {
|
||||
for (k, v) in src {
|
||||
o.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
match serde_json::from_value::<crate::UiState>(cur) {
|
||||
Ok(u) => {
|
||||
app.ui = u;
|
||||
ok(serde_json::to_value(&app.ui).unwrap_or_default())
|
||||
}
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
"ui.view" => {
|
||||
match s("view").unwrap_or("home") {
|
||||
"fit" => app.fit_view(),
|
||||
"home" => {
|
||||
app.cam.set_view(StandardView::Iso);
|
||||
app.fit_view();
|
||||
}
|
||||
v => match StandardView::parse(v) {
|
||||
Some(sv) => app.cam.set_view(sv),
|
||||
None => return err(format!("unknown view `{v}`")),
|
||||
},
|
||||
}
|
||||
ok(json!(app.cam))
|
||||
}
|
||||
"ui.start" => {
|
||||
let Some(id) = s("command") else { return err("missing `command`") };
|
||||
app.start(id);
|
||||
ok(json!({"tool": app.tool.as_ref().map(|t| t.cmd), "dialog": app.dialog.as_ref().map(|d| format!("{d:?}"))}))
|
||||
}
|
||||
"ui.move" | "ui.click" => {
|
||||
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
|
||||
let pos = egui::pos2(x as f32, y as f32);
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
if req.method == "ui.click" {
|
||||
let button = match s("button") {
|
||||
Some("right") => egui::PointerButton::Secondary,
|
||||
Some("middle") => egui::PointerButton::Middle,
|
||||
_ => egui::PointerButton::Primary,
|
||||
};
|
||||
let modifiers = egui::Modifiers { shift: b("shift"), ..Default::default() };
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers });
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers });
|
||||
}
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.scroll" => {
|
||||
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
|
||||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x as f32, y as f32)));
|
||||
app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, f("delta").unwrap_or(-100.0) as f32),
|
||||
modifiers: Default::default(),
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.key" => {
|
||||
let Some(key) = s("key").and_then(key_from) else { return err("unknown key") };
|
||||
let modifiers = egui::Modifiers { shift: b("shift"), command: b("cmd"), ctrl: b("cmd") || b("ctrl"), ..Default::default() };
|
||||
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
|
||||
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.text" => {
|
||||
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.resize" => {
|
||||
let (Some(w), Some(h)) = (f("width"), f("height")) else { return err("missing width/height") };
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w.clamp(320.0, 8192.0) as f32, h.clamp(240.0, 8192.0) as f32)));
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.screenshot" => Outcome::Screenshot { path: s("path").map(str::to_string) },
|
||||
"ui.render" => {
|
||||
let w = f("width").unwrap_or(1280.0).clamp(16.0, 8192.0) as usize;
|
||||
let h = f("height").unwrap_or(800.0).clamp(16.0, 8192.0) as usize;
|
||||
let scene = solvecraft_engine::view::scene(&app.session, &app.cam);
|
||||
let Some(png) = render_png(&scene, &app.cam, w, h) else { return err("render failed") };
|
||||
match s("path") {
|
||||
Some(path) => match std::fs::write(path, &png) {
|
||||
Ok(()) => ok(json!({"path": path, "bytes": png.len()})),
|
||||
Err(e) => err(e),
|
||||
},
|
||||
None => ok(json!({"bytes": png.len()})),
|
||||
}
|
||||
}
|
||||
"app.quit" => {
|
||||
app.quit_requested = true;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
ok(Value::Null)
|
||||
}
|
||||
other => err(format!("unknown method `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save a screenshot PNG.
|
||||
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
|
||||
let [w, h] = image.size;
|
||||
let mut buf = Vec::with_capacity(w * h * 4);
|
||||
for c in &image.pixels {
|
||||
buf.extend_from_slice(&c.to_array());
|
||||
}
|
||||
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, buf) else { return json!({"ok": false, "error": "bad image"}) };
|
||||
let path = path.map(str::to_string).unwrap_or_else(|| std::env::temp_dir().join("solvecraft-shot.png").to_string_lossy().to_string());
|
||||
match img.save(&path) {
|
||||
Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}),
|
||||
Err(e) => json!({"ok": false, "error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
//! Command dialogs (shown at the right of the viewport). A dialog collects inputs and picks,
|
||||
//! then runs its command; it never changes the design itself.
|
||||
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use egui::{Color32, RichText, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_engine::doc::FeatureKind;
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::viewport::Hit;
|
||||
|
||||
const OPS: [&str; 4] = ["new", "join", "cut", "intersect"];
|
||||
const OP_LABELS: [&str; 4] = ["New Body", "Join", "Cut", "Intersect"];
|
||||
const DIRS: [&str; 3] = ["positive", "negative", "symmetric"];
|
||||
const DIR_LABELS: [&str; 3] = ["One side", "Flip", "Symmetric"];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Dialog {
|
||||
Sketch { pick_face: bool, picked: Option<Vec3> },
|
||||
Extrude { profiles: Vec<(u64, usize)>, distance: String, direction: usize, operation: usize },
|
||||
Revolve { profiles: Vec<(u64, usize)>, axis: String, angle: String, operation: usize },
|
||||
Fillet { edges: Vec<Vec3>, radius: String, chamfer: bool },
|
||||
Primitive { cmd: &'static str, fields: Vec<(&'static str, String)>, operation: usize },
|
||||
Combine { target: String, tools: Vec<String>, operation: usize },
|
||||
Params { new_name: String, new_expr: String },
|
||||
EditParam { name: String, expr: String, error: Option<String> },
|
||||
}
|
||||
|
||||
/// Profiles of the sketch a feature would use (active, else the last), when there is just one.
|
||||
fn default_profiles(s: &Session) -> Vec<(u64, usize)> {
|
||||
let st = s.model.state();
|
||||
let sid = s.active_sketch.or_else(|| s.doc.features.iter().rev().find(|f| matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.id));
|
||||
match sid.and_then(|id| st.sketch(id).map(|ss| (id, ss.profiles.len()))) {
|
||||
Some((id, 1)) => vec![(id, 0)],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
pub fn for_command(app: &SolveApp, id: &str) -> Option<Dialog> {
|
||||
let s = &app.session;
|
||||
Some(match id {
|
||||
"SketchCreate" => Dialog::Sketch { pick_face: false, picked: None },
|
||||
"Extrude" => Dialog::Extrude {
|
||||
profiles: default_profiles(s),
|
||||
distance: "10 mm".into(),
|
||||
direction: 0,
|
||||
operation: if s.model.state().bodies.is_empty() { 0 } else { 1 },
|
||||
},
|
||||
"Revolve" => Dialog::Revolve { profiles: default_profiles(s), axis: "y".into(), angle: "360 deg".into(), operation: 0 },
|
||||
"FusionFilletEdgesCommand" => Dialog::Fillet { edges: selected_edges(s), radius: "2 mm".into(), chamfer: false },
|
||||
"FusionChamferCommand" => Dialog::Fillet { edges: selected_edges(s), radius: "1 mm".into(), chamfer: true },
|
||||
"PrimitiveBox" => Dialog::Primitive {
|
||||
cmd: "PrimitiveBox",
|
||||
fields: vec![("length", "20".into()), ("width", "20".into()), ("height", "20".into())],
|
||||
operation: 0,
|
||||
},
|
||||
"PrimitiveCylinder" => {
|
||||
Dialog::Primitive { cmd: "PrimitiveCylinder", fields: vec![("diameter", "20".into()), ("height", "20".into())], operation: 0 }
|
||||
}
|
||||
"PrimitiveSphere" => Dialog::Primitive { cmd: "PrimitiveSphere", fields: vec![("diameter", "20".into())], operation: 0 },
|
||||
"PrimitiveTorus" => {
|
||||
Dialog::Primitive { cmd: "PrimitiveTorus", fields: vec![("major", "20".into()), ("minor", "5".into())], operation: 0 }
|
||||
}
|
||||
"FusionCombineCommand" => {
|
||||
let bodies: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
Dialog::Combine { target: bodies.first().cloned().unwrap_or_default(), tools: bodies.iter().skip(1).cloned().collect(), operation: 1 }
|
||||
}
|
||||
"ChangeParameterCommand" => Dialog::Params { new_name: String::new(), new_expr: String::new() },
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn edit_param(s: &Session, name: &str) -> Dialog {
|
||||
Dialog::EditParam { name: name.into(), expr: s.doc.param(name).map(|p| p.expr.clone()).unwrap_or_default(), error: None }
|
||||
}
|
||||
|
||||
pub fn wants_picks(&self) -> bool {
|
||||
matches!(self, Dialog::Extrude { .. } | Dialog::Revolve { .. } | Dialog::Fillet { .. } | Dialog::Sketch { pick_face: true, .. })
|
||||
}
|
||||
|
||||
pub fn profiles(&self) -> Vec<(u64, usize)> {
|
||||
match self {
|
||||
Dialog::Extrude { profiles, .. } | Dialog::Revolve { profiles, .. } => profiles.clone(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn edges(&self) -> Vec<Vec3> {
|
||||
match self {
|
||||
Dialog::Fillet { edges, .. } => edges.clone(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn highlight_key(&self) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
self.profiles().hash(&mut h);
|
||||
for e in self.edges() {
|
||||
for v in [e.x, e.y, e.z] {
|
||||
v.to_bits().hash(&mut h);
|
||||
}
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
pub fn on_pick(&mut self, hits: &[Hit]) {
|
||||
match self {
|
||||
Dialog::Extrude { profiles, .. } | Dialog::Revolve { profiles, .. } => {
|
||||
if let Some(Hit::Profile { sketch, index }) = hits.iter().find(|h| matches!(h, Hit::Profile { .. })) {
|
||||
let k = (*sketch, *index);
|
||||
if let Some(i) = profiles.iter().position(|p| *p == k) {
|
||||
profiles.remove(i);
|
||||
} else {
|
||||
// Profiles from one sketch only.
|
||||
profiles.retain(|p| p.0 == k.0);
|
||||
profiles.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
Dialog::Fillet { edges, .. } => {
|
||||
if let Some(Hit::Edge { mid, .. }) = hits.iter().find(|h| matches!(h, Hit::Edge { .. })) {
|
||||
if let Some(i) = edges.iter().position(|e| e.dist(*mid) < 1e-6) {
|
||||
edges.remove(i);
|
||||
} else {
|
||||
edges.push(*mid);
|
||||
}
|
||||
}
|
||||
}
|
||||
Dialog::Sketch { picked, .. } => {
|
||||
if let Some(Hit::Face { point, .. }) = hits.iter().find(|h| matches!(h, Hit::Face { .. })) {
|
||||
*picked = Some(*point);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_edges(s: &Session) -> Vec<Vec3> {
|
||||
s.selection.iter().filter_map(|x| if let solvecraft_engine::Sel::Edge { point, .. } = x { Some(*point) } else { None }).collect()
|
||||
}
|
||||
|
||||
fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) {
|
||||
egui::ComboBox::from_id_salt(id).selected_text(labels.get(*sel).copied().unwrap_or("")).width(150.0).show_ui(ui, |ui| {
|
||||
for (i, l) in labels.iter().enumerate() {
|
||||
ui.selectable_value(sel, i, *l);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn profile_param(p: &[(u64, usize)]) -> (Value, Value) {
|
||||
let sketch = p.first().map(|x| json!(x.0)).unwrap_or(Value::Null);
|
||||
(sketch, json!(p.iter().map(|x| x.1).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
/// Show the active dialog (if any).
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.dialog.take() else { return };
|
||||
let t = Tokens::get();
|
||||
let title = match &d {
|
||||
Dialog::Sketch { .. } => "CREATE SKETCH",
|
||||
Dialog::Extrude { .. } => "EXTRUDE",
|
||||
Dialog::Revolve { .. } => "REVOLVE",
|
||||
Dialog::Fillet { chamfer: false, .. } => "FILLET",
|
||||
Dialog::Fillet { chamfer: true, .. } => "CHAMFER",
|
||||
Dialog::Primitive { cmd, .. } => match *cmd {
|
||||
"PrimitiveBox" => "BOX",
|
||||
"PrimitiveCylinder" => "CYLINDER",
|
||||
"PrimitiveSphere" => "SPHERE",
|
||||
_ => "TORUS",
|
||||
},
|
||||
Dialog::Combine { .. } => "COMBINE",
|
||||
Dialog::Params { .. } => "PARAMETERS",
|
||||
Dialog::EditParam { .. } => "EDIT DIMENSION",
|
||||
};
|
||||
let pos = app.viewport.rect.map(|r| egui::pos2(r.right() - 330.0, r.top() + 150.0)).unwrap_or(egui::pos2(900.0, 200.0));
|
||||
let mut keep = true;
|
||||
let mut ok = false;
|
||||
let mut cancel = false;
|
||||
let wide = matches!(d, Dialog::Params { .. });
|
||||
egui::Window::new(RichText::new(title).strong().size(13.0))
|
||||
.id(egui::Id::new("sc_dialog"))
|
||||
.default_pos(if wide { egui::pos2(pos.x - 260.0, pos.y - 60.0) } else { pos })
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.open(&mut keep)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_min_width(if wide { 520.0 } else { 280.0 });
|
||||
egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| match &mut d {
|
||||
Dialog::Sketch { pick_face, .. } => {
|
||||
ui.label("Plane");
|
||||
ui.horizontal(|ui| {
|
||||
for p in ["XY", "XZ", "YZ"] {
|
||||
if ui.button(p).clicked() {
|
||||
if app.run("SketchCreate", json!({"plane": p})).is_ok() {
|
||||
look_at_sketch(app);
|
||||
}
|
||||
cancel = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Face");
|
||||
ui.toggle_value(pick_face, "Pick a planar face");
|
||||
ui.end_row();
|
||||
}
|
||||
Dialog::Extrude { profiles, distance, direction, operation } => {
|
||||
ui.label("Profiles");
|
||||
ui.label(if profiles.is_empty() {
|
||||
RichText::new("click profiles in the view").color(t.warning)
|
||||
} else {
|
||||
RichText::new(format!("{} selected", profiles.len()))
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Direction");
|
||||
combo(ui, "ex_dir", &DIR_LABELS, direction);
|
||||
ui.end_row();
|
||||
ui.label("Distance");
|
||||
ui.text_edit_singleline(distance);
|
||||
ui.end_row();
|
||||
ui.label("Operation");
|
||||
combo(ui, "ex_op", &OP_LABELS, operation);
|
||||
ui.end_row();
|
||||
}
|
||||
Dialog::Revolve { profiles, axis, angle, operation } => {
|
||||
ui.label("Profiles");
|
||||
ui.label(if profiles.is_empty() {
|
||||
RichText::new("click profiles in the view").color(t.warning)
|
||||
} else {
|
||||
RichText::new(format!("{} selected", profiles.len()))
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Axis");
|
||||
ui.text_edit_singleline(axis).on_hover_text("sketch line id, x / y (sketch axes) or X / Y / Z");
|
||||
ui.end_row();
|
||||
ui.label("Angle");
|
||||
ui.text_edit_singleline(angle);
|
||||
ui.end_row();
|
||||
ui.label("Operation");
|
||||
combo(ui, "rv_op", &OP_LABELS, operation);
|
||||
ui.end_row();
|
||||
}
|
||||
Dialog::Fillet { edges, radius, chamfer } => {
|
||||
ui.label("Edges");
|
||||
ui.label(if edges.is_empty() {
|
||||
RichText::new("click edges in the view").color(t.warning)
|
||||
} else {
|
||||
RichText::new(format!("{} selected", edges.len()))
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(if *chamfer { "Distance" } else { "Radius" });
|
||||
ui.text_edit_singleline(radius);
|
||||
ui.end_row();
|
||||
}
|
||||
Dialog::Primitive { fields, operation, .. } => {
|
||||
for (k, v) in fields.iter_mut() {
|
||||
ui.label(*k);
|
||||
ui.text_edit_singleline(v);
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label("Operation");
|
||||
combo(ui, "pr_op", &OP_LABELS, operation);
|
||||
ui.end_row();
|
||||
}
|
||||
Dialog::Combine { target, tools, operation } => {
|
||||
let bodies: Vec<String> = app.session.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
ui.label("Target body");
|
||||
egui::ComboBox::from_id_salt("cb_target").selected_text(target.clone()).show_ui(ui, |ui| {
|
||||
for b in &bodies {
|
||||
ui.selectable_value(target, b.clone(), b);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Tool bodies");
|
||||
ui.vertical(|ui| {
|
||||
for b in bodies.iter().filter(|b| *b != target) {
|
||||
let mut on = tools.contains(b);
|
||||
if ui.checkbox(&mut on, b).changed() {
|
||||
if on {
|
||||
tools.push(b.clone());
|
||||
} else {
|
||||
tools.retain(|x| x != b);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Operation");
|
||||
let mut op = operation.saturating_sub(1);
|
||||
combo(ui, "cb_op", &OP_LABELS[1..], &mut op);
|
||||
*operation = op + 1;
|
||||
ui.end_row();
|
||||
}
|
||||
Dialog::Params { new_name, new_expr } => {
|
||||
ui.label(RichText::new("Name").strong());
|
||||
ui.label(RichText::new("Expression · value").strong());
|
||||
ui.end_row();
|
||||
let (vals, errs) = app.session.doc.param_values();
|
||||
let params = app.session.doc.params.clone();
|
||||
for p in params {
|
||||
ui.label(if p.model { RichText::new(&p.name).color(t.text_dim) } else { RichText::new(&p.name) });
|
||||
ui.horizontal(|ui| {
|
||||
let id = egui::Id::new(("pexpr", &p.name));
|
||||
let mut e = ui.data(|dd| dd.get_temp::<String>(id)).unwrap_or_else(|| p.expr.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut e).desired_width(160.0));
|
||||
if r.changed() {
|
||||
ui.data_mut(|dd| dd.insert_temp(id, e.clone()));
|
||||
}
|
||||
if r.lost_focus() && e != p.expr {
|
||||
let _ = app.run("ChangeParameterCommand", json!({"name": p.name, "expression": e}));
|
||||
ui.data_mut(|dd| dd.remove::<String>(id));
|
||||
}
|
||||
let v = vals
|
||||
.get(&p.name)
|
||||
.map(|v| if p.unit == "deg" { format!("{:.3}°", v.v.to_degrees()) } else { format!("{:.4} {}", v.v, p.unit) });
|
||||
match (v, errs.get(&p.name)) {
|
||||
(_, Some(e)) => ui.label(RichText::new(e).color(t.error)),
|
||||
(Some(v), None) => ui.label(RichText::new(v).color(t.text_dim)),
|
||||
_ => ui.label(""),
|
||||
};
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
ui.add(egui::TextEdit::singleline(new_name).hint_text("new name").desired_width(110.0));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::TextEdit::singleline(new_expr).hint_text("expression, e.g. 25 mm").desired_width(160.0));
|
||||
if ui.button("Add").clicked()
|
||||
&& !new_name.is_empty()
|
||||
&& app.run("ChangeParameterCommand", json!({"name": new_name, "expression": new_expr})).is_ok()
|
||||
{
|
||||
new_name.clear();
|
||||
new_expr.clear();
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
Dialog::EditParam { name, expr, error } => {
|
||||
ui.label(name.as_str());
|
||||
let r = ui.text_edit_singleline(expr);
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
ok = true;
|
||||
}
|
||||
r.request_focus();
|
||||
ui.end_row();
|
||||
if let Some(e) = error {
|
||||
ui.label("");
|
||||
ui.label(RichText::new(e.as_str()).color(t.error));
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
if !matches!(d, Dialog::Sketch { .. } | Dialog::Params { .. })
|
||||
&& ui.add(egui::Button::new(RichText::new("OK").color(Color32::WHITE)).fill(t.accent).min_size(vec2(70.0, 24.0))).clicked()
|
||||
{
|
||||
ok = true;
|
||||
}
|
||||
if ui.add(egui::Button::new(if matches!(d, Dialog::Params { .. }) { "Close" } else { "Cancel" }).min_size(vec2(70.0, 24.0))).clicked()
|
||||
{
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if ok {
|
||||
let r = run_dialog(app, &d);
|
||||
match r {
|
||||
Ok(()) => cancel = true,
|
||||
Err(e) => {
|
||||
if let Dialog::EditParam { error, .. } = &mut d {
|
||||
*error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A face picked in the view while the sketch dialog waits for one.
|
||||
if let Dialog::Sketch { picked: Some(point), .. } = d {
|
||||
if app.run("SketchCreate", json!({"plane": {"face": [point.x, point.y, point.z]}})).is_ok() {
|
||||
look_at_sketch(app);
|
||||
}
|
||||
cancel = true;
|
||||
}
|
||||
if keep && !cancel {
|
||||
app.dialog = Some(d);
|
||||
}
|
||||
}
|
||||
|
||||
fn look_at_sketch(app: &mut SolveApp) {
|
||||
let st = app.session.model.state();
|
||||
if let Some(ss) = app.session.active_sketch.and_then(|id| st.sketch(id)) {
|
||||
let n = ss.plane.normal();
|
||||
let yaw = (-n.x).atan2(-n.y);
|
||||
let pitch = n.z.clamp(-1.0, 1.0).asin();
|
||||
app.cam.yaw = if n.z.abs() > 0.999 { 0.0 } else { yaw };
|
||||
app.cam.pitch = pitch.clamp(-std::f64::consts::FRAC_PI_2 + 1e-4, std::f64::consts::FRAC_PI_2 - 1e-4);
|
||||
app.cam.target = ss.plane.origin;
|
||||
}
|
||||
}
|
||||
|
||||
fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> {
|
||||
match d {
|
||||
Dialog::Extrude { profiles, distance, direction, operation } => {
|
||||
if profiles.is_empty() {
|
||||
return Err("select profiles first".into());
|
||||
}
|
||||
let (sketch, idx) = profile_param(profiles);
|
||||
app.run(
|
||||
"Extrude",
|
||||
json!({"sketch": sketch, "profiles": idx, "distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")}),
|
||||
)
|
||||
.map(drop)
|
||||
}
|
||||
Dialog::Revolve { profiles, axis, angle, operation } => {
|
||||
if profiles.is_empty() {
|
||||
return Err("select profiles first".into());
|
||||
}
|
||||
let (sketch, idx) = profile_param(profiles);
|
||||
app.run(
|
||||
"Revolve",
|
||||
json!({"sketch": sketch, "profiles": idx, "axis": axis, "angle": angle, "operation": OPS.get(*operation).copied().unwrap_or("new")}),
|
||||
)
|
||||
.map(drop)
|
||||
}
|
||||
Dialog::Fillet { edges, radius, chamfer } => {
|
||||
if edges.is_empty() {
|
||||
return Err("select edges first".into());
|
||||
}
|
||||
let pts: Vec<[f64; 3]> = edges.iter().map(|e| [e.x, e.y, e.z]).collect();
|
||||
if *chamfer {
|
||||
app.run("FusionChamferCommand", json!({"edges": pts, "distance": radius})).map(drop)
|
||||
} else {
|
||||
app.run("FusionFilletEdgesCommand", json!({"edges": pts, "radius": radius})).map(drop)
|
||||
}
|
||||
}
|
||||
Dialog::Primitive { cmd, fields, operation } => {
|
||||
let mut p = serde_json::Map::new();
|
||||
for (k, v) in fields {
|
||||
p.insert((*k).into(), json!(v));
|
||||
}
|
||||
p.insert("operation".into(), json!(OPS.get(*operation).copied().unwrap_or("new")));
|
||||
app.run(cmd, Value::Object(p)).map(drop)
|
||||
}
|
||||
Dialog::Combine { target, tools, operation } => app
|
||||
.run("FusionCombineCommand", json!({"target": target, "tools": tools, "operation": OPS.get(*operation).copied().unwrap_or("join")}))
|
||||
.map(drop),
|
||||
Dialog::EditParam { name, expr, .. } => app.run("ChangeParameterCommand", json!({"name": name, "expression": expr})).map(drop),
|
||||
Dialog::Sketch { .. } | Dialog::Params { .. } => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! GPU viewport: shaded triangles and screen-space wide lines with a depth buffer, drawn in an
|
||||
//! `egui_wgpu` paint callback. Geometry is uploaded when the scene changes; each frame only the
|
||||
//! camera uniform changes.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui_wgpu::wgpu;
|
||||
use egui_wgpu::wgpu::util::DeviceExt;
|
||||
|
||||
/// Triangle vertex: position (3 × f32), normal (3 × f32), sRGB colour (4 × u8).
|
||||
pub const TRI_SIZE: usize = 28;
|
||||
/// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32).
|
||||
pub const LINE_SIZE: usize = 32;
|
||||
const UNIFORM_SIZE: u64 = 112;
|
||||
|
||||
/// CPU-side geometry waiting to be uploaded.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct GpuScene {
|
||||
pub tris: Vec<u8>,
|
||||
/// Depth-tested lines.
|
||||
pub lines: Vec<u8>,
|
||||
/// Lines drawn over everything (active sketch, highlights).
|
||||
pub overlay: Vec<u8>,
|
||||
}
|
||||
|
||||
impl GpuScene {
|
||||
pub fn tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) {
|
||||
for v in p.iter().chain(&n) {
|
||||
self.tris.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
self.tris.extend_from_slice(&c);
|
||||
}
|
||||
pub fn line(&mut self, a: [f32; 3], b: [f32; 3], c: [u8; 4], width: f32, on_top: bool) {
|
||||
let out = if on_top { &mut self.overlay } else { &mut self.lines };
|
||||
for v in a.iter().chain(&b) {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&c);
|
||||
out.extend_from_slice(&width.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
pub type SceneSlot = Arc<Mutex<Option<GpuScene>>>;
|
||||
|
||||
/// Per-frame parameters.
|
||||
pub struct ViewportCallback {
|
||||
pub key: u64,
|
||||
pub slot: SceneSlot,
|
||||
/// Column-major view-projection.
|
||||
pub view_proj: [[f32; 4]; 4],
|
||||
/// Direction toward the eye (for lighting).
|
||||
pub back: [f32; 3],
|
||||
/// Viewport size in physical pixels.
|
||||
pub size_px: [f32; 2],
|
||||
}
|
||||
|
||||
struct Batch {
|
||||
buffer: wgpu::Buffer,
|
||||
count: u32,
|
||||
}
|
||||
|
||||
struct Resources {
|
||||
tri: wgpu::RenderPipeline,
|
||||
line: wgpu::RenderPipeline,
|
||||
overlay: wgpu::RenderPipeline,
|
||||
uniform: wgpu::Buffer,
|
||||
bind: wgpu::BindGroup,
|
||||
linear_out: bool,
|
||||
key: Option<u64>,
|
||||
tris: Option<Batch>,
|
||||
lines: Option<Batch>,
|
||||
overlays: Option<Batch>,
|
||||
}
|
||||
|
||||
const SHADER: &str = r#"
|
||||
struct U {
|
||||
vp: mat4x4<f32>,
|
||||
back: vec4<f32>, // xyz = toward the eye, w = 1 for linear output
|
||||
screen: vec4<f32>, // viewport width, height (px), depth bias
|
||||
};
|
||||
@group(0) @binding(0) var<uniform> u: U;
|
||||
|
||||
fn to_linear(c: vec3<f32>) -> vec3<f32> {
|
||||
return select(c, pow((c + vec3<f32>(0.055)) / vec3<f32>(1.055), vec3<f32>(2.4)), c > vec3<f32>(0.04045));
|
||||
}
|
||||
|
||||
fn out_color(c: vec4<f32>) -> vec4<f32> {
|
||||
if (u.back.w > 0.5) {
|
||||
return vec4<f32>(to_linear(c.rgb), c.a);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
struct TOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@location(0) n: vec3<f32>,
|
||||
@location(1) c: vec4<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_tri(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec4<f32>) -> TOut {
|
||||
var o: TOut;
|
||||
o.pos = u.vp * vec4<f32>(p, 1.0);
|
||||
o.n = n;
|
||||
o.c = c;
|
||||
return o;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_tri(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let v = normalize(u.back.xyz);
|
||||
var n = normalize(i.n);
|
||||
if (dot(n, v) < 0.0) { n = -n; }
|
||||
let key = normalize(v * 0.8 + vec3<f32>(-0.3, 0.2, 0.6));
|
||||
let diff = max(dot(n, key), 0.0);
|
||||
let fill = max(dot(n, v), 0.0);
|
||||
let h = normalize(key + v);
|
||||
let spec = pow(max(dot(n, h), 0.0), 40.0) * 0.25;
|
||||
let k = 0.42 + 0.38 * diff + 0.25 * fill;
|
||||
return out_color(vec4<f32>(min(i.c.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), 1.0));
|
||||
}
|
||||
|
||||
struct LOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@location(0) c: vec4<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(1) b: vec3<f32>, @location(2) c: vec4<f32>, @location(3) w: f32) -> LOut {
|
||||
let ca = u.vp * vec4<f32>(a, 1.0);
|
||||
let cb = u.vp * vec4<f32>(b, 1.0);
|
||||
let half = u.screen.xy * 0.5;
|
||||
let sa = ca.xy / max(ca.w, 1e-6) * half;
|
||||
let sb = cb.xy / max(cb.w, 1e-6) * half;
|
||||
var dir = sb - sa;
|
||||
let len = length(dir);
|
||||
if (len < 1e-6) { dir = vec2<f32>(1.0, 0.0); } else { dir = dir / len; }
|
||||
let nrm = vec2<f32>(-dir.y, dir.x);
|
||||
// Two triangles: corners (t, side).
|
||||
var ts = array<f32, 6>(0.0, 1.0, 1.0, 0.0, 1.0, 0.0);
|
||||
var ss = array<f32, 6>(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0);
|
||||
let t = ts[vi];
|
||||
let s = ss[vi];
|
||||
let base = select(ca, cb, t > 0.5);
|
||||
let off = nrm * s * w * 0.5 + dir * (t * 2.0 - 1.0) * w * 0.5;
|
||||
var o: LOut;
|
||||
o.pos = vec4<f32>(base.xy + off / half * base.w, base.z - u.screen.z * base.w, base.w);
|
||||
o.c = c;
|
||||
return o;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_line(i: LOut) -> @location(0) vec4<f32> {
|
||||
return out_color(i.c);
|
||||
}
|
||||
"#;
|
||||
|
||||
fn tri_layout() -> wgpu::VertexBufferLayout<'static> {
|
||||
const A: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4];
|
||||
wgpu::VertexBufferLayout { array_stride: TRI_SIZE as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A }
|
||||
}
|
||||
|
||||
fn line_layout() -> wgpu::VertexBufferLayout<'static> {
|
||||
const A: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4, 3 => Float32];
|
||||
wgpu::VertexBufferLayout { array_stride: LINE_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &A }
|
||||
}
|
||||
|
||||
/// The target configuration: colour format, depth format and MSAA sample count of the app.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct GpuTarget {
|
||||
pub format: wgpu::TextureFormat,
|
||||
pub depth: Option<wgpu::TextureFormat>,
|
||||
pub samples: u32,
|
||||
}
|
||||
|
||||
impl Resources {
|
||||
fn new(device: &wgpu::Device, t: GpuTarget) -> Self {
|
||||
let module =
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_viewport"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
|
||||
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("sc_viewport"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("sc_viewport"),
|
||||
bind_group_layouts: &[Some(&bgl)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("sc_viewport_u"),
|
||||
size: UNIFORM_SIZE,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("sc_viewport"),
|
||||
layout: &bgl,
|
||||
entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }],
|
||||
});
|
||||
let depth = |compare: wgpu::CompareFunction, write: bool| {
|
||||
t.depth.map(|format| wgpu::DepthStencilState {
|
||||
format,
|
||||
depth_write_enabled: Some(write),
|
||||
depth_compare: Some(compare),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
})
|
||||
};
|
||||
let pipeline = |label: &str,
|
||||
vs: &str,
|
||||
fs: &str,
|
||||
buf: wgpu::VertexBufferLayout<'static>,
|
||||
ds: Option<wgpu::DepthStencilState>,
|
||||
blend: Option<wgpu::BlendState>| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState { module: &module, entry_point: Some(vs), buffers: &[Some(buf)], compilation_options: Default::default() },
|
||||
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
|
||||
depth_stencil: ds,
|
||||
multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false },
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(fs),
|
||||
targets: &[Some(wgpu::ColorTargetState { format: t.format, blend, write_mask: wgpu::ColorWrites::ALL })],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let alpha = Some(wgpu::BlendState::ALPHA_BLENDING);
|
||||
Resources {
|
||||
tri: pipeline("sc_tris", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Less, true), None),
|
||||
line: pipeline("sc_lines", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
|
||||
overlay: pipeline("sc_overlay", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::Always, false), alpha),
|
||||
uniform,
|
||||
bind,
|
||||
linear_out: t.format.is_srgb(),
|
||||
key: None,
|
||||
tris: None,
|
||||
lines: None,
|
||||
overlays: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize) -> Option<Batch> {
|
||||
if bytes.len() < stride {
|
||||
return None;
|
||||
}
|
||||
Some(Batch {
|
||||
buffer: device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(label),
|
||||
contents: bytes,
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
}),
|
||||
count: u32::try_from(bytes.len() / stride).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create the pipelines. Call once with the app's render state and its depth/MSAA settings.
|
||||
pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32) -> GpuTarget {
|
||||
let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, 0), samples };
|
||||
let res = Resources::new(&rs.device, t);
|
||||
rs.renderer.write().callback_resources.insert(res);
|
||||
t
|
||||
}
|
||||
|
||||
impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
fn prepare(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
_screen: &egui_wgpu::ScreenDescriptor,
|
||||
_encoder: &mut wgpu::CommandEncoder,
|
||||
resources: &mut egui_wgpu::CallbackResources,
|
||||
) -> Vec<wgpu::CommandBuffer> {
|
||||
let Some(res) = resources.get_mut::<Resources>() else { return Vec::new() };
|
||||
if res.key != Some(self.key)
|
||||
&& let Some(sc) = self.slot.lock().ok().and_then(|mut s| s.take())
|
||||
{
|
||||
res.tris = upload(device, "sc_tris", &sc.tris, TRI_SIZE);
|
||||
res.lines = upload(device, "sc_lines", &sc.lines, LINE_SIZE);
|
||||
res.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE);
|
||||
res.key = Some(self.key);
|
||||
}
|
||||
queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out));
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn paint(&self, info: egui::PaintCallbackInfo, pass: &mut wgpu::RenderPass<'static>, resources: &egui_wgpu::CallbackResources) {
|
||||
let Some(res) = resources.get::<Resources>() else { return };
|
||||
let vp = info.viewport_in_pixels();
|
||||
if vp.width_px <= 0 || vp.height_px <= 0 {
|
||||
return;
|
||||
}
|
||||
pass.set_viewport(vp.left_px as f32, vp.top_px as f32, vp.width_px as f32, vp.height_px as f32, 0.0, 1.0);
|
||||
pass.set_bind_group(0, &res.bind, &[]);
|
||||
if let Some(b) = &res.tris {
|
||||
pass.set_pipeline(&res.tri);
|
||||
pass.set_vertex_buffer(0, b.buffer.slice(..));
|
||||
pass.draw(0..b.count, 0..1);
|
||||
}
|
||||
if let Some(b) = &res.lines {
|
||||
pass.set_pipeline(&res.line);
|
||||
pass.set_vertex_buffer(0, b.buffer.slice(..));
|
||||
pass.draw(0..6, 0..b.count);
|
||||
}
|
||||
if let Some(b) = &res.overlays {
|
||||
pass.set_pipeline(&res.overlay);
|
||||
pass.set_vertex_buffer(0, b.buffer.slice(..));
|
||||
pass.draw(0..6, 0..b.count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uniform bytes for a frame.
|
||||
pub fn uniform_bytes(cb: &ViewportCallback, w: f32, h: f32, linear: bool) -> Vec<u8> {
|
||||
let mut v: Vec<f32> = cb.view_proj.iter().flatten().copied().collect();
|
||||
v.extend([cb.back[0], cb.back[1], cb.back[2], if linear { 1.0 } else { 0.0 }]);
|
||||
v.extend([w.max(1.0), h.max(1.0), 2e-4, 0.0]);
|
||||
v.iter().flat_map(|x| x.to_le_bytes()).collect()
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Toolbar and browser icons, drawn in code (no image assets). Each icon is designed on a
|
||||
//! 24 × 24 grid and scaled to the target rectangle.
|
||||
|
||||
use egui::{Color32, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2};
|
||||
|
||||
struct Pen<'a> {
|
||||
p: &'a Painter,
|
||||
r: Rect,
|
||||
ink: Color32,
|
||||
fill: Color32,
|
||||
accent: Color32,
|
||||
w: f32,
|
||||
}
|
||||
|
||||
impl Pen<'_> {
|
||||
fn at(&self, x: f32, y: f32) -> Pos2 {
|
||||
pos2(self.r.left() + x / 24.0 * self.r.width(), self.r.top() + y / 24.0 * self.r.height())
|
||||
}
|
||||
fn s(&self) -> f32 {
|
||||
self.r.width() / 24.0
|
||||
}
|
||||
fn line(&self, pts: &[(f32, f32)], c: Color32) {
|
||||
let v: Vec<Pos2> = pts.iter().map(|(x, y)| self.at(*x, *y)).collect();
|
||||
self.p.add(Shape::line(v, Stroke::new(self.w, c)));
|
||||
}
|
||||
fn poly(&self, pts: &[(f32, f32)], fill: Color32, c: Color32) {
|
||||
let v: Vec<Pos2> = pts.iter().map(|(x, y)| self.at(*x, *y)).collect();
|
||||
self.p.add(Shape::convex_polygon(v, fill, Stroke::new(self.w, c)));
|
||||
}
|
||||
fn closed(&self, pts: &[(f32, f32)], c: Color32) {
|
||||
let v: Vec<Pos2> = pts.iter().map(|(x, y)| self.at(*x, *y)).collect();
|
||||
self.p.add(Shape::closed_line(v, Stroke::new(self.w, c)));
|
||||
}
|
||||
fn circle(&self, x: f32, y: f32, r: f32, fill: Color32, c: Color32) {
|
||||
self.p.circle(self.at(x, y), r * self.s(), fill, Stroke::new(self.w, c));
|
||||
}
|
||||
fn dot(&self, x: f32, y: f32, c: Color32) {
|
||||
self.p.circle_filled(self.at(x, y), 1.6 * self.s(), c);
|
||||
}
|
||||
fn arc(&self, x: f32, y: f32, r: f32, a0: f32, a1: f32, c: Color32) {
|
||||
let n = 24;
|
||||
let pts: Vec<(f32, f32)> = (0..=n).map(|i| a0 + (a1 - a0) * i as f32 / n as f32).map(|a| (x + r * a.cos(), y - r * a.sin())).collect();
|
||||
self.line(&pts, c);
|
||||
}
|
||||
fn arrow(&self, from: (f32, f32), to: (f32, f32), c: Color32) {
|
||||
self.line(&[from, to], c);
|
||||
let d = vec2(to.0 - from.0, to.1 - from.1).normalized();
|
||||
let n = vec2(-d.y, d.x);
|
||||
let tip = vec2(to.0, to.1);
|
||||
let a = tip - d * 3.5 + n * 2.2;
|
||||
let b = tip - d * 3.5 - n * 2.2;
|
||||
self.poly(&[to, (a.x, a.y), (b.x, b.y)], c, c);
|
||||
}
|
||||
/// Isometric box from a 2D footprint height.
|
||||
fn iso_box(&self, x0: f32, y0: f32, w: f32, d: f32, h: f32) {
|
||||
let top = [(x0, y0), (x0 + w, y0 - d * 0.5), (x0 + w + d, y0), (x0 + d, y0 + d * 0.5)];
|
||||
let front = [(x0, y0), (x0 + d, y0 + d * 0.5), (x0 + d, y0 + d * 0.5 + h), (x0, y0 + h)];
|
||||
let side = [(x0 + d, y0 + d * 0.5), (x0 + w + d, y0), (x0 + w + d, y0 + h), (x0 + d, y0 + d * 0.5 + h)];
|
||||
self.poly(&front, self.fill.gamma_multiply(0.85), self.ink);
|
||||
self.poly(&side, self.fill.gamma_multiply(0.7), self.ink);
|
||||
self.poly(&top, self.fill, self.ink);
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint the icon `name` into `r`. `accent` highlights the "action" part.
|
||||
pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32) {
|
||||
let pen = Pen { p, r, ink, fill, accent, w: (r.width() / 24.0 * 1.4).max(1.0) };
|
||||
let a = pen.accent;
|
||||
match name {
|
||||
"sketch" => {
|
||||
pen.poly(&[(3.0, 14.0), (13.0, 9.0), (21.0, 13.0), (11.0, 18.0)], pen.fill, ink);
|
||||
pen.line(&[(7.0, 13.0), (16.0, 4.0), (18.0, 6.0), (9.0, 15.0), (6.5, 15.5), (7.0, 13.0)], a);
|
||||
}
|
||||
"finish" => {
|
||||
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, pen.accent);
|
||||
pen.line(&[(7.0, 12.5), (10.5, 16.0), (17.0, 8.5)], a);
|
||||
}
|
||||
"extrude" => {
|
||||
pen.iso_box(4.0, 11.0, 9.0, 7.0, 7.0);
|
||||
pen.arrow((12.0, 9.0), (12.0, 2.5), a);
|
||||
}
|
||||
"revolve" => {
|
||||
pen.line(&[(12.0, 2.0), (12.0, 22.0)], ink);
|
||||
pen.poly(&[(13.0, 7.0), (19.0, 7.0), (19.0, 17.0), (13.0, 17.0)], pen.fill, ink);
|
||||
pen.arc(12.0, 12.0, 9.0, 3.6, 5.8, a);
|
||||
pen.arrow((15.0, 20.4), (18.5, 19.0), a);
|
||||
}
|
||||
"box" => pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0),
|
||||
"cylinder" => {
|
||||
pen.poly(&[(5.0, 6.0), (19.0, 6.0), (19.0, 18.0), (5.0, 18.0)], pen.fill.gamma_multiply(0.8), Color32::TRANSPARENT);
|
||||
pen.line(&[(5.0, 6.0), (5.0, 18.0)], ink);
|
||||
pen.line(&[(19.0, 6.0), (19.0, 18.0)], ink);
|
||||
let e = |y: f32, fillc: Color32| {
|
||||
let pts: Vec<Pos2> =
|
||||
(0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 7.0 * t.cos(), y + 2.5 * t.sin())).collect();
|
||||
p.add(Shape::convex_polygon(pts, fillc, Stroke::new(pen.w, ink)));
|
||||
};
|
||||
pen.arc(12.0, 18.0, 7.0, std::f32::consts::PI, std::f32::consts::TAU, ink);
|
||||
e(6.0, pen.fill);
|
||||
}
|
||||
"sphere" => {
|
||||
pen.circle(12.0, 12.0, 8.5, pen.fill, ink);
|
||||
pen.arc(12.0, 12.0, 8.5, 3.3, 6.1, ink.gamma_multiply(0.5));
|
||||
p.circle_filled(pen.at(9.0, 8.5), 2.0 * pen.s(), Color32::from_white_alpha(160));
|
||||
}
|
||||
"torus" => {
|
||||
let pts: Vec<Pos2> =
|
||||
(0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 5.5 * t.sin())).collect();
|
||||
p.add(Shape::convex_polygon(pts, pen.fill, Stroke::new(pen.w, ink)));
|
||||
let pts: Vec<Pos2> =
|
||||
(0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 3.5 * t.cos(), 11.0 + 1.8 * t.sin())).collect();
|
||||
p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink)));
|
||||
}
|
||||
"fillet" => {
|
||||
pen.poly(&[(4.0, 20.0), (4.0, 4.0), (10.0, 4.0), (10.0, 14.0), (20.0, 14.0), (20.0, 20.0)], pen.fill, Color32::TRANSPARENT);
|
||||
pen.line(&[(4.0, 20.0), (4.0, 4.0)], ink);
|
||||
pen.line(&[(4.0, 20.0), (20.0, 20.0)], ink);
|
||||
pen.arc(4.0, 4.0, 16.0, -std::f32::consts::FRAC_PI_2, 0.0, ink);
|
||||
pen.arc(20.0, 4.0, 10.0, std::f32::consts::PI, std::f32::consts::PI * 1.5, a);
|
||||
}
|
||||
"chamfer" => {
|
||||
pen.poly(&[(4.0, 20.0), (4.0, 4.0), (12.0, 4.0), (20.0, 12.0), (20.0, 20.0)], pen.fill, ink);
|
||||
pen.line(&[(12.0, 4.0), (20.0, 12.0)], a);
|
||||
}
|
||||
"combine" => {
|
||||
pen.poly(&[(3.0, 8.0), (14.0, 8.0), (14.0, 19.0), (3.0, 19.0)], pen.fill, ink);
|
||||
pen.poly(&[(9.0, 4.0), (21.0, 4.0), (21.0, 15.0), (9.0, 15.0)], pen.fill.gamma_multiply(0.75), a);
|
||||
}
|
||||
"move" => {
|
||||
for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] {
|
||||
pen.arrow((12.0, 12.0), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), a);
|
||||
}
|
||||
}
|
||||
"params" => {
|
||||
pen.line(&[(8.0, 20.0), (9.0, 8.0), (10.0, 5.0), (13.0, 4.0)], ink);
|
||||
pen.line(&[(6.0, 11.0), (12.0, 11.0)], ink);
|
||||
pen.line(&[(14.0, 13.0), (20.0, 20.0)], a);
|
||||
pen.line(&[(20.0, 13.0), (14.0, 20.0)], a);
|
||||
}
|
||||
"compute" => {
|
||||
pen.arc(12.0, 12.0, 7.5, 0.4, 3.0, ink);
|
||||
pen.arc(12.0, 12.0, 7.5, 3.5, 6.0, a);
|
||||
pen.arrow((5.0, 9.0), (4.6, 13.0), ink);
|
||||
pen.arrow((19.0, 15.0), (19.4, 11.0), a);
|
||||
}
|
||||
"delete" => {
|
||||
pen.line(&[(6.0, 6.0), (18.0, 18.0)], pen.accent);
|
||||
pen.line(&[(18.0, 6.0), (6.0, 18.0)], pen.accent);
|
||||
}
|
||||
"measure" => {
|
||||
pen.poly(&[(2.0, 15.0), (15.0, 2.0), (22.0, 9.0), (9.0, 22.0)], pen.fill, ink);
|
||||
for i in 0..5 {
|
||||
let t = 4.0 + i as f32 * 2.6;
|
||||
pen.line(&[(t, 15.0 - (t - 2.0)), (t + 2.0, 15.0 - (t - 2.0) + 2.0)], ink);
|
||||
}
|
||||
}
|
||||
"line" => {
|
||||
pen.line(&[(4.0, 19.0), (20.0, 5.0)], a);
|
||||
pen.dot(4.0, 19.0, ink);
|
||||
pen.dot(20.0, 5.0, ink);
|
||||
}
|
||||
"rect" | "rect3" | "rect_center" => {
|
||||
pen.closed(&[(4.0, 6.0), (20.0, 6.0), (20.0, 18.0), (4.0, 18.0)], a);
|
||||
match name {
|
||||
"rect" => {
|
||||
pen.dot(4.0, 18.0, ink);
|
||||
pen.dot(20.0, 6.0, ink);
|
||||
}
|
||||
"rect3" => {
|
||||
pen.dot(4.0, 18.0, ink);
|
||||
pen.dot(20.0, 18.0, ink);
|
||||
pen.dot(20.0, 6.0, ink);
|
||||
}
|
||||
_ => {
|
||||
pen.dot(12.0, 12.0, ink);
|
||||
pen.dot(20.0, 6.0, ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
"circle" | "circle2" | "circle3" => {
|
||||
pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, a);
|
||||
match name {
|
||||
"circle" => {
|
||||
pen.dot(12.0, 12.0, ink);
|
||||
pen.line(&[(12.0, 12.0), (17.6, 6.4)], ink);
|
||||
}
|
||||
"circle2" => {
|
||||
pen.dot(4.0, 12.0, ink);
|
||||
pen.dot(20.0, 12.0, ink);
|
||||
}
|
||||
_ => {
|
||||
pen.dot(4.0, 12.0, ink);
|
||||
pen.dot(12.0, 4.0, ink);
|
||||
pen.dot(20.0, 12.0, ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
"arc3" | "arc_center" => {
|
||||
pen.arc(12.0, 16.0, 9.0, 0.0, std::f32::consts::PI, a);
|
||||
pen.dot(3.0, 16.0, ink);
|
||||
pen.dot(21.0, 16.0, ink);
|
||||
if name == "arc3" {
|
||||
pen.dot(12.0, 7.0, ink);
|
||||
} else {
|
||||
pen.dot(12.0, 16.0, ink);
|
||||
}
|
||||
}
|
||||
"polygon" => {
|
||||
let pts: Vec<(f32, f32)> =
|
||||
(0..6).map(|i| i as f32 / 6.0 * std::f32::consts::TAU + 0.52).map(|t| (12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
|
||||
pen.closed(&pts, a);
|
||||
pen.dot(12.0, 12.0, ink);
|
||||
}
|
||||
"slot" => {
|
||||
pen.arc(8.0, 12.0, 5.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI * 1.5, a);
|
||||
pen.arc(16.0, 12.0, 5.0, -std::f32::consts::FRAC_PI_2, std::f32::consts::FRAC_PI_2, a);
|
||||
pen.line(&[(8.0, 7.0), (16.0, 7.0)], a);
|
||||
pen.line(&[(8.0, 17.0), (16.0, 17.0)], a);
|
||||
pen.dot(8.0, 12.0, ink);
|
||||
pen.dot(16.0, 12.0, ink);
|
||||
}
|
||||
"point" => {
|
||||
pen.dot(12.0, 12.0, a);
|
||||
pen.circle(12.0, 12.0, 4.0, Color32::TRANSPARENT, ink);
|
||||
}
|
||||
"dimension" => {
|
||||
pen.line(&[(4.0, 18.0), (4.0, 8.0)], ink);
|
||||
pen.line(&[(20.0, 18.0), (20.0, 8.0)], ink);
|
||||
pen.arrow((12.0, 11.0), (4.5, 11.0), a);
|
||||
pen.arrow((12.0, 11.0), (19.5, 11.0), a);
|
||||
pen.line(&[(9.0, 15.0), (15.0, 15.0)], ink);
|
||||
}
|
||||
"construction" => {
|
||||
for i in 0..4 {
|
||||
let t = i as f32 * 4.5;
|
||||
pen.line(&[(3.0 + t, 19.0 - t), (5.5 + t, 16.5 - t)], pen.accent);
|
||||
}
|
||||
}
|
||||
"c_hv" => {
|
||||
pen.line(&[(4.0, 18.0), (20.0, 18.0)], a);
|
||||
pen.line(&[(6.0, 4.0), (6.0, 14.0)], a);
|
||||
}
|
||||
"c_coincident" => {
|
||||
pen.line(&[(3.0, 18.0), (12.0, 12.0)], ink);
|
||||
pen.line(&[(12.0, 12.0), (21.0, 16.0)], ink);
|
||||
pen.circle(12.0, 12.0, 2.6, Color32::WHITE, a);
|
||||
}
|
||||
"c_tangent" => {
|
||||
pen.circle(12.0, 14.0, 6.0, Color32::TRANSPARENT, ink);
|
||||
pen.line(&[(3.0, 8.0), (21.0, 8.0)], a);
|
||||
}
|
||||
"c_equal" => {
|
||||
pen.line(&[(5.0, 9.0), (19.0, 9.0)], a);
|
||||
pen.line(&[(5.0, 15.0), (19.0, 15.0)], a);
|
||||
}
|
||||
"c_parallel" => {
|
||||
pen.line(&[(5.0, 19.0), (13.0, 5.0)], a);
|
||||
pen.line(&[(11.0, 19.0), (19.0, 5.0)], a);
|
||||
}
|
||||
"c_perpendicular" => {
|
||||
pen.line(&[(4.0, 19.0), (20.0, 19.0)], a);
|
||||
pen.line(&[(12.0, 19.0), (12.0, 5.0)], a);
|
||||
pen.line(&[(12.0, 15.0), (16.0, 15.0), (16.0, 19.0)], ink);
|
||||
}
|
||||
"c_fix" => {
|
||||
pen.circle(12.0, 9.0, 4.0, Color32::TRANSPARENT, a);
|
||||
pen.line(&[(12.0, 13.0), (12.0, 21.0)], a);
|
||||
pen.line(&[(9.0, 21.0), (15.0, 21.0)], ink);
|
||||
}
|
||||
"c_midpoint" => {
|
||||
pen.line(&[(3.0, 15.0), (21.0, 9.0)], ink);
|
||||
pen.poly(&[(12.0, 9.0), (15.0, 12.0), (12.0, 15.0), (9.0, 12.0)], a, a);
|
||||
}
|
||||
"c_concentric" => {
|
||||
pen.circle(12.0, 12.0, 8.5, Color32::TRANSPARENT, a);
|
||||
pen.circle(12.0, 12.0, 4.0, Color32::TRANSPARENT, a);
|
||||
}
|
||||
"c_collinear" => {
|
||||
pen.line(&[(3.0, 19.0), (10.0, 12.0)], a);
|
||||
pen.line(&[(14.0, 8.0), (21.0, 1.0)], a);
|
||||
}
|
||||
"c_symmetry" => {
|
||||
pen.line(&[(12.0, 3.0), (12.0, 21.0)], ink);
|
||||
pen.poly(&[(4.0, 8.0), (9.0, 12.0), (4.0, 16.0)], a, a);
|
||||
pen.poly(&[(20.0, 8.0), (15.0, 12.0), (20.0, 16.0)], a, a);
|
||||
}
|
||||
"undo" | "redo" => {
|
||||
let (x0, x1) = if name == "undo" { (19.0, 5.0) } else { (5.0, 19.0) };
|
||||
pen.line(&[(x0, 18.0), (x0, 12.0), (12.0, 9.0), (x1, 9.0)], ink);
|
||||
pen.arrow((x1 + (x0 - x1) * 0.15, 9.0), (x1, 9.0), ink);
|
||||
}
|
||||
"new" => {
|
||||
pen.poly(&[(6.0, 3.0), (15.0, 3.0), (19.0, 7.0), (19.0, 21.0), (6.0, 21.0)], Color32::WHITE, ink);
|
||||
pen.line(&[(15.0, 3.0), (15.0, 7.0), (19.0, 7.0)], ink);
|
||||
}
|
||||
"open" => {
|
||||
pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (20.0, 9.0), (20.0, 19.0), (3.0, 19.0)], pen.fill, ink);
|
||||
}
|
||||
"save" => {
|
||||
pen.poly(&[(4.0, 4.0), (17.0, 4.0), (20.0, 7.0), (20.0, 20.0), (4.0, 20.0)], pen.fill, ink);
|
||||
pen.poly(&[(8.0, 4.0), (16.0, 4.0), (16.0, 9.0), (8.0, 9.0)], Color32::WHITE, ink);
|
||||
}
|
||||
"export" => {
|
||||
pen.poly(&[(4.0, 10.0), (4.0, 20.0), (16.0, 20.0), (16.0, 10.0)], pen.fill, ink);
|
||||
pen.arrow((10.0, 14.0), (20.0, 4.0), a);
|
||||
}
|
||||
"home" => {
|
||||
pen.poly(&[(4.0, 12.0), (12.0, 4.0), (20.0, 12.0)], pen.fill, ink);
|
||||
pen.poly(&[(6.0, 12.0), (18.0, 12.0), (18.0, 20.0), (6.0, 20.0)], pen.fill, ink);
|
||||
}
|
||||
"fit" => {
|
||||
for (cx, cy, dx, dy) in [(4.0, 4.0, 1.0, 1.0), (20.0, 4.0, -1.0, 1.0), (4.0, 20.0, 1.0, -1.0), (20.0, 20.0, -1.0, -1.0)] {
|
||||
pen.line(&[(cx + dx * 5.0, cy), (cx, cy), (cx, cy + dy * 5.0)], ink);
|
||||
}
|
||||
pen.poly(&[(9.0, 9.0), (15.0, 9.0), (15.0, 15.0), (9.0, 15.0)], pen.fill, ink);
|
||||
}
|
||||
"orbit" => {
|
||||
pen.arc(12.0, 12.0, 8.0, 0.3, 5.5, ink);
|
||||
pen.arrow((17.0, 17.5), (19.6, 14.5), a);
|
||||
pen.dot(12.0, 12.0, ink);
|
||||
}
|
||||
"pan" => {
|
||||
for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] {
|
||||
pen.arrow((12.0 + 3.0 * dx, 12.0 + 3.0 * dy), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), ink);
|
||||
}
|
||||
}
|
||||
"zoom" => {
|
||||
pen.circle(10.0, 10.0, 6.0, Color32::WHITE, ink);
|
||||
pen.line(&[(14.5, 14.5), (20.0, 20.0)], ink);
|
||||
}
|
||||
"perspective" => {
|
||||
pen.closed(&[(4.0, 6.0), (20.0, 4.0), (20.0, 20.0), (4.0, 18.0)], ink);
|
||||
pen.closed(&[(9.0, 9.0), (15.0, 8.5), (15.0, 15.5), (9.0, 15.0)], ink);
|
||||
}
|
||||
"eye" => {
|
||||
pen.arc(12.0, 18.0, 10.0, 0.6, 2.54, ink);
|
||||
pen.arc(12.0, 6.0, 10.0, 3.74, 5.68, ink);
|
||||
pen.circle(12.0, 12.0, 2.6, ink, ink);
|
||||
}
|
||||
"body" => pen.iso_box(4.0, 10.0, 8.0, 6.0, 6.0),
|
||||
"folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], Color32::from_rgb(236, 200, 110), ink),
|
||||
"origin" => {
|
||||
pen.arrow((6.0, 18.0), (20.0, 18.0), Color32::from_rgb(210, 60, 60));
|
||||
pen.arrow((6.0, 18.0), (6.0, 4.0), Color32::from_rgb(60, 100, 220));
|
||||
pen.arrow((6.0, 18.0), (15.0, 9.0), Color32::from_rgb(60, 160, 60));
|
||||
}
|
||||
"plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], Color32::from_rgb(244, 214, 150), ink),
|
||||
"settings" => {
|
||||
pen.circle(12.0, 12.0, 6.5, pen.fill, ink);
|
||||
pen.circle(12.0, 12.0, 2.5, Color32::WHITE, ink);
|
||||
for i in 0..8 {
|
||||
let t = i as f32 / 8.0 * std::f32::consts::TAU;
|
||||
pen.line(&[(12.0 + 6.5 * t.cos(), 12.0 + 6.5 * t.sin()), (12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())], ink);
|
||||
}
|
||||
}
|
||||
"warning" => {
|
||||
pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], Color32::from_rgb(250, 200, 60), ink);
|
||||
pen.line(&[(12.0, 9.0), (12.0, 14.0)], ink);
|
||||
pen.dot(12.0, 17.0, ink);
|
||||
}
|
||||
_ => {
|
||||
pen.poly(&[(5.0, 5.0), (19.0, 5.0), (19.0, 19.0), (5.0, 19.0)], pen.fill, ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,322 @@
|
||||
//! The SolveCraft egui front end.
|
||||
//!
|
||||
//! A thin shell over [`solvecraft_engine::Session`]: panels read engine state and act through
|
||||
//! commands ([`SolveApp::run`]). Nothing here owns design data. The layout follows the familiar
|
||||
//! parametric-CAD desktop: application bar, a toolbar of workspace tabs and panels, the browser
|
||||
//! on the left, the 3D viewport with a view cube and navigation bar, the command dialog on the
|
||||
//! right of the viewport, the command palette and the timeline at the bottom.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod browser;
|
||||
pub mod control;
|
||||
pub mod dialogs;
|
||||
pub mod gpu;
|
||||
pub mod icons;
|
||||
pub mod palette;
|
||||
pub mod theme;
|
||||
pub mod timeline;
|
||||
pub mod toolbar;
|
||||
pub mod tools;
|
||||
pub mod viewport;
|
||||
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_engine::render::Camera;
|
||||
|
||||
pub use control::{ControlRequest, ControlResponse};
|
||||
|
||||
/// UI state that automation can read and set (`ui.set`).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct UiState {
|
||||
/// Toolbar tab: SOLID, SURFACE, MESH, SHEET METAL, PLASTIC, UTILITIES (SKETCH while sketching).
|
||||
pub tab: String,
|
||||
pub show_browser: bool,
|
||||
pub show_timeline: bool,
|
||||
pub show_grid: bool,
|
||||
pub show_origin: bool,
|
||||
pub perspective: bool,
|
||||
pub hidden_bodies: Vec<String>,
|
||||
pub show_sketches: bool,
|
||||
pub palette_open: bool,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
UiState {
|
||||
tab: "SOLID".into(),
|
||||
show_browser: true,
|
||||
show_timeline: true,
|
||||
show_grid: true,
|
||||
show_origin: true,
|
||||
perspective: false,
|
||||
hidden_bodies: Vec::new(),
|
||||
show_sketches: true,
|
||||
palette_open: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Host services (file pickers) injected by the app so this crate stays portable.
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
|
||||
pub pick_save: Option<Box<dyn Fn(&str, &[&str]) -> Option<String>>>,
|
||||
}
|
||||
|
||||
pub struct SolveApp {
|
||||
pub session: Session,
|
||||
pub ui: UiState,
|
||||
pub cam: Camera,
|
||||
pub services: Services,
|
||||
pub viewport: viewport::ViewportState,
|
||||
pub tool: Option<tools::Tool>,
|
||||
pub dialog: Option<dialogs::Dialog>,
|
||||
pub palette: palette::Palette,
|
||||
pub status: Option<(String, f64, bool)>,
|
||||
pub quit_requested: bool,
|
||||
pub frame_ms: f64,
|
||||
pub synthetic: Vec<egui::Event>,
|
||||
control_rx: Option<Receiver<ControlRequest>>,
|
||||
pending_shots: Vec<(u64, Option<String>, std::sync::mpsc::Sender<Value>, f64)>,
|
||||
queued_shots: Vec<(u64, f64, u32)>,
|
||||
shot_token: u64,
|
||||
styled: bool,
|
||||
fitted: bool,
|
||||
}
|
||||
|
||||
impl SolveApp {
|
||||
pub fn new(session: Session, services: Services) -> Self {
|
||||
SolveApp {
|
||||
session,
|
||||
ui: UiState::default(),
|
||||
cam: Camera::default(),
|
||||
services,
|
||||
viewport: viewport::ViewportState::default(),
|
||||
tool: None,
|
||||
dialog: None,
|
||||
palette: palette::Palette::default(),
|
||||
status: None,
|
||||
quit_requested: false,
|
||||
frame_ms: 0.0,
|
||||
synthetic: Vec::new(),
|
||||
control_rx: None,
|
||||
pending_shots: Vec::new(),
|
||||
queued_shots: Vec::new(),
|
||||
shot_token: 0,
|
||||
styled: false,
|
||||
fitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the viewport on the GPU (eframe's wgpu render state, with the app's depth buffer
|
||||
/// bits and MSAA sample count). Without it the viewport renders on the CPU.
|
||||
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32) {
|
||||
self.viewport.gpu = Some(gpu::install(rs, depth_bits, samples));
|
||||
}
|
||||
|
||||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
|
||||
self.control_rx = Some(rx);
|
||||
self
|
||||
}
|
||||
|
||||
/// Run a command programmatically (JSON parameters, no dialogs).
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string());
|
||||
match &r {
|
||||
Ok(_) => {
|
||||
self.session.echo(format!("{id}: done"));
|
||||
if matches!(id, "NewDocumentCommand" | "doc.open") {
|
||||
self.cam = Camera::default();
|
||||
self.fit_view();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.session.echo(format!("{id}: {e}"));
|
||||
self.set_status(e.clone(), true);
|
||||
}
|
||||
}
|
||||
if self.session.active_sketch.is_some() && self.ui.tab != "SKETCH" {
|
||||
self.ui.tab = "SKETCH".into();
|
||||
} else if self.session.active_sketch.is_none() && self.ui.tab == "SKETCH" {
|
||||
self.ui.tab = "SOLID".into();
|
||||
self.tool = None;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Start a command the way a toolbar click does: interactive tools and dialogs for commands
|
||||
/// that have them, otherwise run it with defaults.
|
||||
pub fn start(&mut self, id: &str) {
|
||||
self.tool = None;
|
||||
if let Some(t) = tools::Tool::for_command(id) {
|
||||
self.tool = Some(t);
|
||||
self.set_status(tools::hint(id), false);
|
||||
return;
|
||||
}
|
||||
if let Some(d) = dialogs::Dialog::for_command(self, id) {
|
||||
self.dialog = Some(d);
|
||||
return;
|
||||
}
|
||||
let _ = self.run(id, json!({}));
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, s: impl Into<String>, error: bool) {
|
||||
self.status = Some((s.into(), now_ms(), error));
|
||||
}
|
||||
|
||||
pub fn open_path(&mut self, path: &str) {
|
||||
if self.run("doc.open", json!({ "path": path })).is_ok() {
|
||||
self.fit_view();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fit_view(&mut self) {
|
||||
let b = solvecraft_engine::view::bounds(&self.session);
|
||||
if b.is_empty() {
|
||||
self.cam.target = solvecraft_engine::geom::Vec3::ZERO;
|
||||
self.cam.distance = 150.0;
|
||||
} else {
|
||||
self.cam.fit(&b);
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame logic before layout.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
if !self.styled {
|
||||
theme::apply(ctx);
|
||||
self.styled = true;
|
||||
}
|
||||
if !self.fitted {
|
||||
self.fit_view();
|
||||
self.fitted = true;
|
||||
}
|
||||
self.drain_control(ctx);
|
||||
if !self.synthetic.is_empty() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
self.collect_screenshots(ctx);
|
||||
self.issue_screenshots(ctx);
|
||||
toolbar::shortcuts(self, ctx);
|
||||
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||
let p = f.path().to_string_lossy().to_string();
|
||||
if !p.is_empty() {
|
||||
self.open_path(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject synthetic events (one pointer event per frame).
|
||||
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
|
||||
if self.synthetic.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = match self.synthetic.first() {
|
||||
Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }) => 1,
|
||||
_ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1),
|
||||
};
|
||||
if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() {
|
||||
raw.events.push(egui::Event::PointerMoved(*p));
|
||||
}
|
||||
let n = n.min(self.synthetic.len());
|
||||
raw.events.extend(self.synthetic.drain(..n));
|
||||
}
|
||||
|
||||
/// Lay out the whole window.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
let t0 = now_ms();
|
||||
toolbar::app_bar(self, ui);
|
||||
toolbar::toolbar(self, ui);
|
||||
if self.ui.show_timeline {
|
||||
timeline::timeline(self, ui);
|
||||
}
|
||||
palette::palette_bar(self, ui);
|
||||
if self.ui.show_browser {
|
||||
browser::browser(self, ui);
|
||||
}
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
|
||||
viewport::show(self, ui);
|
||||
});
|
||||
dialogs::show(self, ui.ctx());
|
||||
palette::popup(self, ui.ctx());
|
||||
self.frame_ms = now_ms() - t0;
|
||||
}
|
||||
|
||||
fn drain_control(&mut self, ctx: &egui::Context) {
|
||||
let Some(rx) = self.control_rx.take() else { return };
|
||||
while let Ok(req) = rx.try_recv() {
|
||||
let reply = req.reply.clone();
|
||||
match control::handle(self, ctx, &req) {
|
||||
control::Outcome::Done(v) => {
|
||||
let _ = reply.send(v);
|
||||
}
|
||||
control::Outcome::Screenshot { path } => {
|
||||
self.shot_token += 1;
|
||||
let token = self.shot_token;
|
||||
self.queued_shots.push((token, now_ms() + 150.0, 0));
|
||||
self.pending_shots.push((token, path, reply, now_ms() + 8000.0));
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
self.control_rx = Some(rx);
|
||||
}
|
||||
|
||||
fn issue_screenshots(&mut self, ctx: &egui::Context) {
|
||||
let now = now_ms();
|
||||
self.queued_shots.retain_mut(|(token, at, frames)| {
|
||||
*frames += 1;
|
||||
if now >= *at && *frames >= 3 {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token)));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_screenshots(&mut self, ctx: &egui::Context) {
|
||||
if self.pending_shots.is_empty() {
|
||||
return;
|
||||
}
|
||||
let events: Vec<_> = ctx.input(|i| {
|
||||
i.raw
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::Screenshot { user_data, image, .. } => {
|
||||
let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).copied()?;
|
||||
Some((token, image.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
for (token, image) in events {
|
||||
if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) {
|
||||
let (_, path, reply, _) = self.pending_shots.remove(i);
|
||||
let _ = reply.send(control::save_screenshot(&image, path.as_deref()));
|
||||
}
|
||||
}
|
||||
let now = now_ms();
|
||||
self.pending_shots.retain(|(_, _, reply, deadline)| {
|
||||
if now < *deadline {
|
||||
return true;
|
||||
}
|
||||
let _ = reply.send(json!({"ok": false, "error": "no frame was presented (window hidden?); use ui.render"}));
|
||||
false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn now_ms() -> f64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! The command line / palette: type a command id with JSON parameters
|
||||
//! (`Extrude {"distance": 20}`), or part of a command name to search (press S anywhere).
|
||||
|
||||
use egui::{RichText, vec2};
|
||||
use serde_json::Value;
|
||||
use solvecraft_engine::command_specs;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Palette {
|
||||
pub text: String,
|
||||
pub history: Vec<String>,
|
||||
focus: bool,
|
||||
}
|
||||
|
||||
/// Commands whose id or label matches the query.
|
||||
pub fn matches(q: &str) -> Vec<(&'static str, &'static str, &'static str)> {
|
||||
let q = q.trim().to_ascii_lowercase();
|
||||
let mut v: Vec<(&'static str, &'static str, &'static str)> = command_specs()
|
||||
.into_iter()
|
||||
.filter(|c| q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q))
|
||||
.map(|c| (c.id, c.label, c.icon))
|
||||
.collect();
|
||||
v.sort_by_key(|(id, label, _)| (!label.to_ascii_lowercase().starts_with(&q), !id.to_ascii_lowercase().starts_with(&q), *label));
|
||||
v
|
||||
}
|
||||
|
||||
/// Execute a line of palette input.
|
||||
pub fn submit(app: &mut SolveApp, line: &str) {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return;
|
||||
}
|
||||
app.palette.history.push(line.to_string());
|
||||
let (id, rest) = match line.find(|c: char| c.is_whitespace() || c == '{') {
|
||||
Some(i) => (&line[..i], line[i..].trim()),
|
||||
None => (line, ""),
|
||||
};
|
||||
if solvecraft_engine::find_command(id).is_some() {
|
||||
if rest.is_empty() {
|
||||
app.start(id);
|
||||
} else {
|
||||
match serde_json::from_str::<Value>(rest) {
|
||||
Ok(p) => {
|
||||
let _ = app.run(id, p);
|
||||
}
|
||||
Err(e) => app.set_status(format!("parameters must be JSON: {e}"), true),
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some((id, _, _)) = matches(line).first() {
|
||||
app.start(id);
|
||||
} else {
|
||||
app.set_status(format!("no command matches `{line}`"), true);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn palette_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
egui::Panel::bottom("sc_palette").exact_size(28.0).frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 3))).show(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.label(RichText::new("›").strong().color(t.accent));
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut app.palette.text)
|
||||
.hint_text("Type a command, e.g. Extrude {\"distance\": 20} — or press S to search")
|
||||
.desired_width(520.0),
|
||||
);
|
||||
if app.palette.focus {
|
||||
r.request_focus();
|
||||
app.palette.focus = false;
|
||||
}
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
let line = std::mem::take(&mut app.palette.text);
|
||||
submit(app, &line);
|
||||
}
|
||||
if let Some(last) = app.session.log.last() {
|
||||
ui.label(RichText::new(last).size(11.5).color(t.text_dim));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The search popup (S).
|
||||
pub fn popup(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if !app.ui.palette_open {
|
||||
return;
|
||||
}
|
||||
let mut open = true;
|
||||
let mut picked: Option<&'static str> = None;
|
||||
let t = Tokens::get();
|
||||
let center = ctx.content_rect().center();
|
||||
egui::Window::new("Search commands")
|
||||
.id(egui::Id::new("sc_search"))
|
||||
.fixed_pos(egui::pos2(center.x - 200.0, 150.0))
|
||||
.fixed_size(vec2(400.0, 340.0))
|
||||
.collapsible(false)
|
||||
.open(&mut open)
|
||||
.show(ctx, |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut app.palette.text).hint_text("command name").desired_width(380.0));
|
||||
r.request_focus();
|
||||
let list = matches(&app.palette.text);
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
picked = list.first().map(|x| x.0);
|
||||
}
|
||||
egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| {
|
||||
for (id, label, icon) in list.iter().take(60) {
|
||||
let enabled = solvecraft_engine::find_command(id).is_some_and(|c| c.info(&app.session).enabled);
|
||||
ui.horizontal(|ui| {
|
||||
let (ir, _) = ui.allocate_exact_size(vec2(18.0, 18.0), egui::Sense::hover());
|
||||
crate::icons::paint(ui.painter(), ir, icon, t.icon, t.icon_fill, t.accent);
|
||||
if ui.add_enabled(enabled, egui::Button::new(*label).frame(false)).clicked() {
|
||||
picked = Some(id);
|
||||
}
|
||||
ui.label(RichText::new(*id).size(10.5).color(t.text_dim));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(id) = picked {
|
||||
app.palette.text.clear();
|
||||
app.ui.palette_open = false;
|
||||
app.start(id);
|
||||
} else if !open || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.palette_open = false;
|
||||
app.palette.text.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Colours and widget style. A light theme in the spirit of desktop CAD tools; every colour
|
||||
//! the UI uses comes from [`Tokens`].
|
||||
|
||||
use egui::{Color32, Stroke};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Tokens {
|
||||
pub app_bar: Color32,
|
||||
pub toolbar: Color32,
|
||||
pub tab_active: Color32,
|
||||
pub panel: Color32,
|
||||
pub panel_header: Color32,
|
||||
pub border: Color32,
|
||||
pub text: Color32,
|
||||
pub text_dim: Color32,
|
||||
pub accent: Color32,
|
||||
pub accent_soft: Color32,
|
||||
pub sketch_accent: Color32,
|
||||
pub error: Color32,
|
||||
pub warning: Color32,
|
||||
pub icon: Color32,
|
||||
pub icon_fill: Color32,
|
||||
pub timeline: Color32,
|
||||
pub hover: Color32,
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
pub const fn get() -> Tokens {
|
||||
Tokens {
|
||||
app_bar: Color32::from_rgb(52, 56, 64),
|
||||
toolbar: Color32::from_rgb(246, 247, 249),
|
||||
tab_active: Color32::from_rgb(255, 255, 255),
|
||||
panel: Color32::from_rgb(250, 251, 252),
|
||||
panel_header: Color32::from_rgb(236, 238, 242),
|
||||
border: Color32::from_rgb(208, 212, 220),
|
||||
text: Color32::from_rgb(38, 42, 50),
|
||||
text_dim: Color32::from_rgb(110, 116, 128),
|
||||
accent: Color32::from_rgb(38, 120, 218),
|
||||
accent_soft: Color32::from_rgb(214, 230, 250),
|
||||
sketch_accent: Color32::from_rgb(46, 150, 90),
|
||||
error: Color32::from_rgb(214, 54, 54),
|
||||
warning: Color32::from_rgb(222, 150, 30),
|
||||
icon: Color32::from_rgb(70, 78, 92),
|
||||
icon_fill: Color32::from_rgb(178, 196, 222),
|
||||
timeline: Color32::from_rgb(232, 235, 240),
|
||||
hover: Color32::from_rgb(226, 234, 246),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply(ctx: &egui::Context) {
|
||||
let t = Tokens::get();
|
||||
let mut v = egui::Visuals::light();
|
||||
v.panel_fill = t.panel;
|
||||
v.window_fill = t.panel;
|
||||
v.extreme_bg_color = Color32::WHITE;
|
||||
v.selection.bg_fill = t.accent_soft;
|
||||
v.selection.stroke = Stroke::new(1.0, t.accent);
|
||||
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.border);
|
||||
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text);
|
||||
v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text);
|
||||
v.widgets.hovered.weak_bg_fill = t.hover;
|
||||
v.widgets.hovered.bg_fill = t.hover;
|
||||
v.window_corner_radius = egui::CornerRadius::same(6);
|
||||
ctx.set_visuals(v);
|
||||
ctx.global_style_mut(|s| {
|
||||
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
|
||||
s.spacing.button_padding = egui::vec2(6.0, 3.0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! The timeline (bottom): features in order, the rollback marker, errors and warnings.
|
||||
|
||||
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::doc::FeatureKind;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{SolveApp, icons};
|
||||
|
||||
fn icon_of(k: &FeatureKind) -> &'static str {
|
||||
match k {
|
||||
FeatureKind::Sketch { .. } => "sketch",
|
||||
FeatureKind::Extrude { .. } => "extrude",
|
||||
FeatureKind::Revolve { .. } => "revolve",
|
||||
FeatureKind::Fillet { .. } => "fillet",
|
||||
FeatureKind::Chamfer { .. } => "chamfer",
|
||||
FeatureKind::Box { .. } => "box",
|
||||
FeatureKind::Cylinder { .. } => "cylinder",
|
||||
FeatureKind::Sphere { .. } => "sphere",
|
||||
FeatureKind::Torus { .. } => "torus",
|
||||
FeatureKind::Combine { .. } => "combine",
|
||||
FeatureKind::Move { .. } => "move",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
egui::Panel::bottom("sc_timeline").exact_size(40.0).frame(egui::Frame::NONE.fill(t.timeline)).show(ui, |ui| {
|
||||
let r = ui.max_rect();
|
||||
let p = ui.painter().clone();
|
||||
p.line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.border));
|
||||
let n = app.session.doc.features.len();
|
||||
let marker = app.session.doc.marker.unwrap_or(n);
|
||||
// Playback controls.
|
||||
let mut x = r.left() + 10.0;
|
||||
let controls = [("⏮", Some(0usize)), ("◀", Some(marker.saturating_sub(1))), ("▶", Some((marker + 1).min(n))), ("⏭", None)];
|
||||
for (label, target) in controls {
|
||||
let br = Rect::from_center_size(pos2(x + 11.0, r.center().y), vec2(22.0, 22.0));
|
||||
let resp = ui.interact(br, ui.id().with(("tl", label)), Sense::click());
|
||||
if resp.hovered() {
|
||||
p.rect_filled(br, 3.0, t.hover);
|
||||
}
|
||||
p.text(br.center(), Align2::CENTER_CENTER, label, FontId::proportional(13.0), t.icon);
|
||||
if resp.clicked() {
|
||||
let pos = match target {
|
||||
Some(k) if k < n => json!({"position": k}),
|
||||
_ => json!({}),
|
||||
};
|
||||
let _ = app.run("timeline.rollback", pos);
|
||||
}
|
||||
x += 24.0;
|
||||
}
|
||||
x += 14.0;
|
||||
let feats: Vec<_> =
|
||||
app.session.doc.features.iter().map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name())).collect();
|
||||
let mut edit: Option<u64> = None;
|
||||
for (i, (id, name, icon, suppressed, ty)) in feats.iter().enumerate() {
|
||||
if i == marker {
|
||||
p.rect_filled(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(4.0, r.height() - 10.0)), 1.0, Color32::from_rgb(70, 76, 88));
|
||||
x += 8.0;
|
||||
}
|
||||
let br = Rect::from_min_size(pos2(x, r.top() + 6.0), vec2(28.0, 28.0));
|
||||
let resp = ui.interact(br, ui.id().with(("feat", *id)), Sense::click());
|
||||
let res = app.session.model.result(*id);
|
||||
let err = res.and_then(|r| r.error.clone());
|
||||
let warn = res.and_then(|r| r.warning.clone());
|
||||
let rolled = i >= marker;
|
||||
let sel = app.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::Feature { id: f } if f == id));
|
||||
let bg = if err.is_some() {
|
||||
Color32::from_rgb(250, 220, 220)
|
||||
} else if sel {
|
||||
t.accent_soft
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
Color32::WHITE
|
||||
};
|
||||
p.rect(br, 3.0, bg, Stroke::new(1.0, if err.is_some() { t.error } else { t.border }), egui::StrokeKind::Inside);
|
||||
let ink = if rolled || *suppressed { t.border } else { t.icon };
|
||||
icons::paint(&p, br.shrink(4.0), icon, ink, if rolled { t.panel_header } else { t.icon_fill }, if rolled { t.border } else { t.accent });
|
||||
if warn.is_some() && err.is_none() {
|
||||
icons::paint(&p, Rect::from_min_size(br.right_top() - vec2(10.0, 0.0), vec2(10.0, 10.0)), "warning", t.icon, t.warning, t.warning);
|
||||
}
|
||||
let mut tip = format!("{name} ({ty})");
|
||||
if let Some(e) = &err {
|
||||
tip += &format!("\nError: {e}");
|
||||
}
|
||||
if let Some(w) = &warn {
|
||||
tip += &format!("\nWarning: {w}");
|
||||
}
|
||||
if let Some(r) = res {
|
||||
tip += &format!("\n{:.1} ms", r.ms);
|
||||
}
|
||||
let resp = resp.on_hover_text(tip);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]}));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
edit = Some(*id);
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button(if *suppressed { "Unsuppress" } else { "Suppress" }).clicked() {
|
||||
let _ = app.run("timeline.suppress", json!({"feature": id}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Roll back to here").clicked() {
|
||||
let _ = app.run("timeline.rollback", json!({"position": i}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Delete").clicked() {
|
||||
let _ = app.run("FusionDeleteCommand", json!({"features": [id.to_string()]}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
x += 32.0;
|
||||
}
|
||||
if marker >= feats.len() {
|
||||
p.rect_filled(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(4.0, r.height() - 10.0)), 1.0, Color32::from_rgb(70, 76, 88));
|
||||
}
|
||||
if let Some(id) = edit {
|
||||
if matches!(app.session.doc.feature(id).map(|f| &f.kind), Some(FeatureKind::Sketch { .. })) {
|
||||
let _ = app.run("SketchActivate", json!({"sketch": id}));
|
||||
} else {
|
||||
app.palette.text = format!("timeline.edit {{\"feature\": {id}, \"set\": {{}}}}");
|
||||
app.ui.palette_open = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Application bar, the workspace toolbar (tabs and panels) and keyboard shortcuts.
|
||||
|
||||
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::command_specs;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{SolveApp, icons};
|
||||
|
||||
pub const TABS: &[&str] = &["SOLID", "SURFACE", "MESH", "SHEET METAL", "PLASTIC", "UTILITIES"];
|
||||
|
||||
/// Panels shown on each tab (in order). Commands come from the registry by (tab, panel).
|
||||
fn panels(tab: &str) -> &'static [&'static str] {
|
||||
match tab {
|
||||
"SOLID" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
|
||||
"SKETCH" => &["CREATE", "MODIFY", "CONSTRAINTS", "INSPECT", "INSERT", "SELECT", "FINISH SKETCH"],
|
||||
"SURFACE" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
|
||||
"MESH" => &["CREATE", "PREPARE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
|
||||
"SHEET METAL" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
|
||||
"PLASTIC" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
|
||||
_ => &["MAKE", "ADD-INS", "UTILITY", "INSPECT", "SELECT"],
|
||||
}
|
||||
}
|
||||
|
||||
/// How many commands of a panel get a big button.
|
||||
fn promoted(tab: &str, panel: &str) -> usize {
|
||||
match (tab, panel) {
|
||||
("SOLID", "CREATE") => 4,
|
||||
("SOLID", "MODIFY") => 4,
|
||||
("SKETCH", "CREATE") => 6,
|
||||
("SKETCH", "CONSTRAINTS") => 6,
|
||||
(_, "FINISH SKETCH") => 1,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
egui::Panel::top("sc_appbar").exact_size(34.0).frame(egui::Frame::NONE.fill(t.app_bar)).show(ui, |ui| {
|
||||
let r = ui.max_rect();
|
||||
// Brand mark: a small solid block drawn in code.
|
||||
let logo = Rect::from_center_size(pos2(r.left() + 20.0, r.center().y), vec2(20.0, 20.0));
|
||||
icons::paint(ui.painter(), logo, "box", Color32::from_rgb(220, 226, 236), Color32::from_rgb(90, 160, 240), Color32::WHITE);
|
||||
ui.painter().text(
|
||||
pos2(r.left() + 36.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
"SolveCraft",
|
||||
FontId::proportional(14.0),
|
||||
Color32::from_rgb(230, 234, 242),
|
||||
);
|
||||
let mut x = r.left() + 128.0;
|
||||
let mut click = |ui: &mut egui::Ui, icon: &str, tip: &str| -> bool {
|
||||
let br = Rect::from_center_size(pos2(x, r.center().y), vec2(26.0, 26.0));
|
||||
x += 30.0;
|
||||
let resp = ui.interact(br, ui.id().with(("ab", icon)), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30));
|
||||
}
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
br.shrink(5.0),
|
||||
icon,
|
||||
Color32::from_rgb(225, 230, 238),
|
||||
Color32::from_rgb(120, 140, 170),
|
||||
Color32::from_rgb(120, 190, 255),
|
||||
);
|
||||
resp.on_hover_text(tip).clicked()
|
||||
};
|
||||
let file = click(ui, "folder", "File");
|
||||
if click(ui, "save", "Save (Ctrl+S)") {
|
||||
save(app);
|
||||
}
|
||||
if click(ui, "undo", "Undo (Ctrl+Z)") {
|
||||
let _ = app.run("UndoCommand", json!({}));
|
||||
}
|
||||
if click(ui, "redo", "Redo (Ctrl+Y)") {
|
||||
let _ = app.run("RedoCommand", json!({}));
|
||||
}
|
||||
if file {
|
||||
app.ui.palette_open = false;
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_file_menu"), true));
|
||||
}
|
||||
// Document tab.
|
||||
let name = format!("{}{}", app.session.doc.name, if app.session.is_dirty() { " •" } else { "" });
|
||||
let tab = Rect::from_min_size(pos2(r.center().x - 110.0, r.top() + 5.0), vec2(220.0, r.height() - 5.0));
|
||||
ui.painter().rect_filled(tab, egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, t.toolbar);
|
||||
ui.painter().text(tab.center(), Align2::CENTER_CENTER, name, FontId::proportional(13.0), t.text);
|
||||
ui.painter().text(
|
||||
pos2(r.right() - 12.0, r.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
format!("v{}", env!("CARGO_PKG_VERSION")),
|
||||
FontId::proportional(11.0),
|
||||
Color32::from_rgb(160, 168, 182),
|
||||
);
|
||||
});
|
||||
file_menu(app, ui.ctx());
|
||||
}
|
||||
|
||||
fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let id = egui::Id::new("sc_file_menu");
|
||||
if !ctx.data(|d| d.get_temp::<bool>(id).unwrap_or(false)) {
|
||||
return;
|
||||
}
|
||||
let mut close = false;
|
||||
let resp = egui::Area::new(egui::Id::new("sc_file_area")).fixed_pos(pos2(100.0, 34.0)).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(190.0);
|
||||
let item = |ui: &mut egui::Ui, label: &str, key: &str| {
|
||||
ui.add(egui::Button::new(label).shortcut_text(key).frame(false).min_size(vec2(180.0, 22.0))).clicked()
|
||||
};
|
||||
if item(ui, "New Design", "Ctrl+N") {
|
||||
let _ = app.run("NewDocumentCommand", json!({}));
|
||||
app.fit_view();
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Open…", "Ctrl+O") {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
app.open_path(&p);
|
||||
}
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Save", "Ctrl+S") {
|
||||
save(app);
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Save As…", "") {
|
||||
save_as(app);
|
||||
close = true;
|
||||
}
|
||||
ui.separator();
|
||||
if item(ui, "Export STL…", "") {
|
||||
export(app, "stl");
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Export STEP…", "") {
|
||||
export(app, "step");
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Export OBJ…", "") {
|
||||
export(app, "obj");
|
||||
close = true;
|
||||
}
|
||||
ui.separator();
|
||||
if item(ui, "Quit", "") {
|
||||
app.quit_requested = true;
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if close || resp.response.clicked_elsewhere() {
|
||||
ctx.data_mut(|d| d.insert_temp(id, false));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(app: &mut SolveApp) {
|
||||
if app.session.path.is_some() {
|
||||
let _ = app.run("SaveDocumentCommand", json!({}));
|
||||
} else {
|
||||
save_as(app);
|
||||
}
|
||||
}
|
||||
|
||||
fn save_as(app: &mut SolveApp) {
|
||||
let name = format!("{}.{}", app.session.doc.name, solvecraft_engine::io::DESIGN_EXT);
|
||||
if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[solvecraft_engine::io::DESIGN_EXT])) {
|
||||
let _ = app.run("SaveDocumentAsCommand", json!({"path": p}));
|
||||
}
|
||||
}
|
||||
|
||||
fn export(app: &mut SolveApp, ext: &str) {
|
||||
let name = format!("{}.{ext}", app.session.doc.name);
|
||||
if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[ext]))
|
||||
&& app.run("ExportCommand", json!({"path": p})).is_ok()
|
||||
{
|
||||
app.set_status(format!("Exported {p}"), false);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
let sketching = app.session.active_sketch.is_some();
|
||||
egui::Panel::top("sc_toolbar").exact_size(92.0).frame(egui::Frame::NONE.fill(t.toolbar)).show(ui, |ui| {
|
||||
let r = ui.max_rect();
|
||||
let painter = ui.painter().clone();
|
||||
// Workspace selector and tabs.
|
||||
let tabs_y = r.top() + 2.0;
|
||||
let ws = Rect::from_min_size(pos2(r.left() + 8.0, tabs_y + 26.0), vec2(96.0, 56.0));
|
||||
painter.rect(ws, 4.0, Color32::WHITE, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
painter.text(pos2(ws.center().x - 5.0, ws.top() + 14.0), Align2::CENTER_CENTER, "DESIGN", FontId::proportional(12.0), t.text);
|
||||
caret(&painter, pos2(ws.center().x + 26.0, ws.top() + 14.0), t.text);
|
||||
painter.text(pos2(ws.center().x, ws.top() + 34.0), Align2::CENTER_CENTER, "workspace", FontId::proportional(10.0), t.text_dim);
|
||||
let mut x = r.left() + 112.0;
|
||||
let mut tabs: Vec<&str> = Vec::new();
|
||||
if sketching {
|
||||
tabs.push("SKETCH");
|
||||
}
|
||||
tabs.extend(TABS.iter());
|
||||
for tab in tabs {
|
||||
let w = 12.0 + tab.len() as f32 * 7.4;
|
||||
let tr = Rect::from_min_size(pos2(x, tabs_y), vec2(w, 22.0));
|
||||
let active = app.ui.tab == tab;
|
||||
let resp = ui.interact(tr, ui.id().with(("tab", tab)), Sense::click());
|
||||
if active {
|
||||
painter.rect_filled(tr, egui::CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.tab_active);
|
||||
let c = if tab == "SKETCH" { t.sketch_accent } else { t.accent };
|
||||
painter.line_segment([pos2(tr.left() + 4.0, tr.bottom() - 1.0), pos2(tr.right() - 4.0, tr.bottom() - 1.0)], Stroke::new(2.0, c));
|
||||
} else if resp.hovered() {
|
||||
painter.rect_filled(tr, 4.0, t.hover);
|
||||
}
|
||||
let col = if tab == "SKETCH" {
|
||||
t.sketch_accent
|
||||
} else if active {
|
||||
t.text
|
||||
} else {
|
||||
t.text_dim
|
||||
};
|
||||
painter.text(tr.center(), Align2::CENTER_CENTER, tab, FontId::proportional(12.0), col);
|
||||
if resp.clicked() {
|
||||
app.ui.tab = tab.to_string();
|
||||
}
|
||||
x += w + 4.0;
|
||||
}
|
||||
// Panels.
|
||||
let tab = app.ui.tab.clone();
|
||||
let specs = command_specs();
|
||||
let mut px = r.left() + 112.0;
|
||||
let top = r.top() + 26.0;
|
||||
for panel in panels(&tab) {
|
||||
let cmds: Vec<_> = specs.iter().filter(|c| c.tab == tab && c.panel == *panel).collect();
|
||||
let n = promoted(&tab, panel).min(cmds.len()).max(if cmds.is_empty() { 1 } else { 0 });
|
||||
let width = (n as f32 * 40.0).max(64.0) + 8.0;
|
||||
let enabled_panel = !cmds.is_empty();
|
||||
for (i, c) in cmds.iter().take(n).enumerate() {
|
||||
let br = Rect::from_min_size(pos2(px + 4.0 + i as f32 * 40.0, top + 2.0), vec2(38.0, 38.0));
|
||||
let info = c.info(&app.session);
|
||||
let resp = ui.interact(br, ui.id().with(("cmd", c.id)), Sense::click());
|
||||
if resp.hovered() && info.enabled {
|
||||
painter.rect_filled(br, 4.0, t.hover);
|
||||
}
|
||||
let (ink, fill, acc) = if info.enabled {
|
||||
(t.icon, t.icon_fill, if tab == "SKETCH" { t.sketch_accent } else { t.accent })
|
||||
} else {
|
||||
(t.border, t.panel_header, t.border)
|
||||
};
|
||||
let icon = if c.id == "SketchStop" { "finish" } else { c.icon };
|
||||
icons::paint(&painter, br.shrink(6.0), icon, ink, fill, acc);
|
||||
let tip = match &info.disabled_reason {
|
||||
Some(why) => format!("{}\n{why}", c.label),
|
||||
None => format!("{}{}", c.label, c.shortcut.map(|k| format!(" ({k})")).unwrap_or_default()),
|
||||
};
|
||||
if resp.on_hover_text(tip).clicked() && info.enabled {
|
||||
let id = c.id;
|
||||
app.start(id);
|
||||
}
|
||||
}
|
||||
if cmds.is_empty() {
|
||||
let br = Rect::from_min_size(pos2(px + 4.0, top + 2.0), vec2(width - 8.0, 38.0));
|
||||
painter.text(br.center(), Align2::CENTER_CENTER, "—", FontId::proportional(14.0), t.border);
|
||||
}
|
||||
// Panel label with drop-down.
|
||||
let lr = Rect::from_min_size(pos2(px, top + 42.0), vec2(width, 18.0));
|
||||
let label = panel.to_string();
|
||||
let lresp = ui.interact(lr, ui.id().with(("panel", panel)), Sense::click());
|
||||
if lresp.hovered() && enabled_panel {
|
||||
painter.rect_filled(lr, 3.0, t.hover);
|
||||
}
|
||||
let lc = if *panel == "FINISH SKETCH" {
|
||||
t.sketch_accent
|
||||
} else if enabled_panel {
|
||||
t.text
|
||||
} else {
|
||||
t.text_dim
|
||||
};
|
||||
let g = painter.layout_no_wrap(label, FontId::proportional(10.5), lc);
|
||||
let gx = lr.center().x - (g.size().x + 9.0) / 2.0;
|
||||
let gy = lr.center().y - g.size().y / 2.0;
|
||||
let gw = g.size().x;
|
||||
painter.galley(pos2(gx, gy), g, lc);
|
||||
caret(&painter, pos2(gx + gw + 6.0, lr.center().y), lc);
|
||||
let popup_id = ui.id().with(("panel_menu", panel));
|
||||
if lresp.clicked() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_panel_open"), popup_id));
|
||||
}
|
||||
let open = ui.ctx().data(|d| d.get_temp::<egui::Id>(egui::Id::new("sc_panel_open"))) == Some(popup_id);
|
||||
if open {
|
||||
let area = egui::Area::new(popup_id).fixed_pos(lr.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(200.0);
|
||||
if cmds.is_empty() {
|
||||
ui.label(egui::RichText::new("Not available yet").color(t.text_dim));
|
||||
}
|
||||
for c in &cmds {
|
||||
let info = c.info(&app.session);
|
||||
ui.horizontal(|ui| {
|
||||
let (ir, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
icons::paint(ui.painter(), ir, c.icon, t.icon, t.icon_fill, t.accent);
|
||||
let b = ui.add_enabled(info.enabled, egui::Button::new(c.label).frame(false).shortcut_text(c.shortcut.unwrap_or("")));
|
||||
if b.clicked() {
|
||||
let id = c.id;
|
||||
app.start(id);
|
||||
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
if area.response.clicked_elsewhere() && !lresp.clicked() {
|
||||
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
|
||||
}
|
||||
}
|
||||
px += width;
|
||||
painter.line_segment([pos2(px, top + 4.0), pos2(px, top + 58.0)], Stroke::new(1.0, t.border));
|
||||
px += 2.0;
|
||||
}
|
||||
painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border));
|
||||
});
|
||||
}
|
||||
|
||||
/// A small drop-down caret.
|
||||
fn caret(p: &egui::Painter, c: egui::Pos2, col: Color32) {
|
||||
p.add(egui::Shape::convex_polygon(vec![pos2(c.x - 3.5, c.y - 2.0), pos2(c.x + 3.5, c.y - 2.0), pos2(c.x, c.y + 2.5)], col, Stroke::NONE));
|
||||
}
|
||||
|
||||
/// Global keyboard shortcuts (when no text field has focus).
|
||||
pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if ctx.egui_wants_keyboard_input() {
|
||||
return;
|
||||
}
|
||||
let (keys, mods) = ctx.input(|i| {
|
||||
let keys: Vec<egui::Key> =
|
||||
i.events.iter().filter_map(|e| if let egui::Event::Key { key, pressed: true, .. } = e { Some(*key) } else { None }).collect();
|
||||
(keys, i.modifiers)
|
||||
});
|
||||
for k in keys {
|
||||
use egui::Key;
|
||||
if mods.command {
|
||||
match k {
|
||||
Key::Z => drop(app.run("UndoCommand", json!({}))),
|
||||
Key::Y => drop(app.run("RedoCommand", json!({}))),
|
||||
Key::S => save(app),
|
||||
Key::N => {
|
||||
let _ = app.run("NewDocumentCommand", json!({}));
|
||||
app.fit_view();
|
||||
}
|
||||
Key::O => {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
app.open_path(&p);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match k {
|
||||
Key::Escape => {
|
||||
if app.tool.is_some() {
|
||||
app.tool = None;
|
||||
} else if app.dialog.is_some() {
|
||||
app.dialog = None;
|
||||
} else {
|
||||
let _ = app.run("select.clear", json!({}));
|
||||
}
|
||||
}
|
||||
Key::S => app.ui.palette_open = true,
|
||||
Key::Delete | Key::Backspace => crate::viewport::delete_selection(app),
|
||||
_ => {
|
||||
let name = format!("{k:?}");
|
||||
if let Some(c) = command_specs().into_iter().find(|c| c.shortcut == Some(name.as_str()) && c.info(&app.session).enabled) {
|
||||
app.start(c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Interactive sketch tools: click points on the sketch plane (or pick sketch entities) and the
|
||||
//! tool runs the matching command. Tools are UI state only; the work is done by commands.
|
||||
|
||||
use egui::{Color32, Pos2, Shape, Stroke};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::geom::Vec2;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::viewport::{Hit, Proj, pick, sketch_point_at};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// Click points; `n` points complete the shape (0 = chain until finished).
|
||||
Draw(usize),
|
||||
/// Pick sketch entities; `n` picks run the command.
|
||||
Pick(usize),
|
||||
Dimension,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Tool {
|
||||
pub cmd: &'static str,
|
||||
pub kind: Kind,
|
||||
/// Clicked points (sketch coordinates) and the sketch point they snapped to.
|
||||
pub pts: Vec<(Vec2, Option<String>)>,
|
||||
pub hover: Option<(Vec2, Option<String>)>,
|
||||
/// Picked entity ids.
|
||||
pub picks: Vec<String>,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
pub fn for_command(id: &str) -> Option<Tool> {
|
||||
let (cmd, kind): (&'static str, Kind) = match id {
|
||||
"DrawPolyline" => ("DrawPolyline", Kind::Draw(0)),
|
||||
"ShapeRectangleTwoPoint" => ("ShapeRectangleTwoPoint", Kind::Draw(2)),
|
||||
"ShapeRectangleCenter" => ("ShapeRectangleCenter", Kind::Draw(2)),
|
||||
"ShapeRectangleThreePoint" => ("ShapeRectangleThreePoint", Kind::Draw(3)),
|
||||
"CircleCenterRadius" => ("CircleCenterRadius", Kind::Draw(2)),
|
||||
"CircleTwoPoint" => ("CircleTwoPoint", Kind::Draw(2)),
|
||||
"CircleThreePoint" => ("CircleThreePoint", Kind::Draw(3)),
|
||||
"ArcThreePoint" => ("ArcThreePoint", Kind::Draw(3)),
|
||||
"ArcCenterTwoPoint" => ("ArcCenterTwoPoint", Kind::Draw(3)),
|
||||
"ShapePolygonInscribed" => ("ShapePolygonInscribed", Kind::Draw(2)),
|
||||
"ShapePolygonCircumscribed" => ("ShapePolygonCircumscribed", Kind::Draw(2)),
|
||||
"ShapePolygonEdge" => ("ShapePolygonEdge", Kind::Draw(2)),
|
||||
"ShapeSlotCenterToCenter" => ("ShapeSlotCenterToCenter", Kind::Draw(3)),
|
||||
"ShapeSlotOverall" => ("ShapeSlotOverall", Kind::Draw(3)),
|
||||
"DrawPoint" => ("DrawPoint", Kind::Draw(1)),
|
||||
"SketchDimension" => ("SketchDimension", Kind::Dimension),
|
||||
"ConstraintHorizontalVertical" => ("ConstraintHorizontalVertical", Kind::Pick(1)),
|
||||
"ConstraintFix" => ("ConstraintFix", Kind::Pick(1)),
|
||||
"ConstraintCoincident" => ("ConstraintCoincident", Kind::Pick(2)),
|
||||
"ConstraintTangent" => ("ConstraintTangent", Kind::Pick(2)),
|
||||
"ConstraintEqual" => ("ConstraintEqual", Kind::Pick(2)),
|
||||
"ConstraintParallel" => ("ConstraintParallel", Kind::Pick(2)),
|
||||
"ConstraintPerpendicular" => ("ConstraintPerpendicular", Kind::Pick(2)),
|
||||
"ConstraintConcentric" => ("ConstraintConcentric", Kind::Pick(2)),
|
||||
"ConstraintCollinear" => ("ConstraintCollinear", Kind::Pick(2)),
|
||||
"ConstraintMidPoint" => ("ConstraintMidPoint", Kind::Pick(2)),
|
||||
"ConstraintSymmetry" => ("ConstraintSymmetry", Kind::Pick(3)),
|
||||
"sketch.construction" => ("sketch.construction", Kind::Pick(1)),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Tool { cmd, kind, pts: Vec::new(), hover: None, picks: Vec::new() })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hint(id: &str) -> String {
|
||||
match id {
|
||||
"DrawPolyline" => "Line: click points; right-click or Esc to finish".into(),
|
||||
"ShapeRectangleTwoPoint" => "Rectangle: click two opposite corners".into(),
|
||||
"ShapeRectangleCenter" => "Center rectangle: click the centre, then a corner".into(),
|
||||
"ShapeRectangleThreePoint" => "3-point rectangle: click two points of one edge, then the width".into(),
|
||||
"CircleCenterRadius" => "Circle: click the centre, then a point on the circle".into(),
|
||||
"ArcThreePoint" => "Arc: click start, end, then a point on the arc".into(),
|
||||
"ArcCenterTwoPoint" => "Arc: click centre, start, then end (counter-clockwise)".into(),
|
||||
"SketchDimension" => "Dimension: pick a line, circle or arc (or two points/lines)".into(),
|
||||
"ShapeSlotCenterToCenter" | "ShapeSlotOverall" => "Slot: click both ends, then the width".into(),
|
||||
_ if id.starts_with("Constraint") => "Constraint: pick the sketch entities".into(),
|
||||
_ => "Click in the sketch".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn arg(p: &(Vec2, Option<String>)) -> Value {
|
||||
match &p.1 {
|
||||
Some(id) => json!(id),
|
||||
None => json!([p.0.x, p.0.y]),
|
||||
}
|
||||
}
|
||||
|
||||
fn xy(p: &(Vec2, Option<String>)) -> Value {
|
||||
json!([p.0.x, p.0.y])
|
||||
}
|
||||
|
||||
pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
|
||||
let h = sketch_point_at(app, proj, pos);
|
||||
if let Some(t) = app.tool.as_mut() {
|
||||
t.hover = h;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
|
||||
let Some(mut tool) = app.tool.take() else { return };
|
||||
match tool.kind {
|
||||
Kind::Draw(n) => {
|
||||
if let Some(p) = sketch_point_at(app, proj, pos) {
|
||||
tool.pts.push(p);
|
||||
}
|
||||
if tool.cmd == "DrawPolyline" {
|
||||
if tool.pts.len() >= 2 {
|
||||
let (a, b) = (tool.pts[tool.pts.len() - 2].clone(), tool.pts[tool.pts.len() - 1].clone());
|
||||
if let Ok(v) = app.run("DrawPolyline", json!({"points": [arg(&a), arg(&b)], "infer": true})) {
|
||||
// Chain: the next segment starts at this one's end point.
|
||||
if let Some(id) = v["curves"].get(0).and_then(Value::as_str) {
|
||||
let last = tool.pts.len() - 1;
|
||||
tool.pts[last].1 = Some(format!("{id}.end"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if n > 0 && tool.pts.len() >= n {
|
||||
run_shape(app, &tool);
|
||||
tool.pts.clear();
|
||||
}
|
||||
}
|
||||
Kind::Pick(n) => {
|
||||
let hits = pick(app, proj, pos);
|
||||
if let Some(id) = hits.iter().find_map(|h| match h {
|
||||
Hit::SketchPoint { id, .. } | Hit::SketchCurve { id, .. } => Some(id.clone()),
|
||||
_ => None,
|
||||
}) {
|
||||
tool.picks.push(id);
|
||||
}
|
||||
if tool.picks.len() >= n {
|
||||
run_pick(app, &tool);
|
||||
tool.picks.clear();
|
||||
}
|
||||
}
|
||||
Kind::Dimension => {
|
||||
let hits = pick(app, proj, pos);
|
||||
let hit = hits.iter().find_map(|h| match h {
|
||||
Hit::SketchPoint { id, .. } => Some((id.clone(), true)),
|
||||
Hit::SketchCurve { id, .. } => Some((id.clone(), false)),
|
||||
_ => None,
|
||||
});
|
||||
if let Some((id, is_point)) = hit {
|
||||
tool.picks.push(id.clone());
|
||||
let st = app.session.model.state();
|
||||
let is_line = app
|
||||
.session
|
||||
.active_sketch
|
||||
.and_then(|s| st.sketch(s))
|
||||
.and_then(|ss| ss.sketch.curve_index(&id).and_then(|c| ss.sketch.curves.get(c).cloned()))
|
||||
.map(|c| matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. }));
|
||||
let ready = (tool.picks.len() == 1 && !is_point && is_line != Some(true)) || tool.picks.len() >= 2;
|
||||
let single_line = tool.picks.len() == 1 && is_line == Some(true);
|
||||
if ready || single_line {
|
||||
// A single line gets its length right away; a second pick (line or point) replaces it.
|
||||
if let Ok(v) = app.run("SketchDimension", json!({"entities": tool.picks}))
|
||||
&& let Some(p) = v["param"].as_str()
|
||||
{
|
||||
app.dialog = Some(crate::dialogs::Dialog::edit_param(&app.session, p));
|
||||
}
|
||||
tool.picks.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
app.tool = Some(tool);
|
||||
}
|
||||
|
||||
fn run_shape(app: &mut SolveApp, t: &Tool) {
|
||||
let p = &t.pts;
|
||||
let d = |a: usize, b: usize| p[a].0.dist(p[b].0);
|
||||
let params = match t.cmd {
|
||||
"ShapeRectangleTwoPoint" => json!({"p0": xy(&p[0]), "p1": xy(&p[1])}),
|
||||
"ShapeRectangleCenter" => json!({"center": xy(&p[0]), "corner": xy(&p[1])}),
|
||||
"ShapeRectangleThreePoint" => json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "p2": xy(&p[2])}),
|
||||
"CircleCenterRadius" => json!({"center": arg(&p[0]), "radius": d(0, 1)}),
|
||||
"CircleTwoPoint" => json!({"p0": xy(&p[0]), "p1": xy(&p[1])}),
|
||||
"CircleThreePoint" => json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "p2": xy(&p[2])}),
|
||||
"ArcThreePoint" => json!({"start": arg(&p[0]), "end": arg(&p[1]), "through": xy(&p[2])}),
|
||||
"ArcCenterTwoPoint" => json!({"center": arg(&p[0]), "start": arg(&p[1]), "end": xy(&p[2])}),
|
||||
"ShapePolygonInscribed" | "ShapePolygonCircumscribed" => {
|
||||
let a = (p[1].0 - p[0].0).angle().to_degrees();
|
||||
json!({"center": xy(&p[0]), "radius": d(0, 1), "sides": 6, "angle": a})
|
||||
}
|
||||
"ShapePolygonEdge" => json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "sides": 6}),
|
||||
"ShapeSlotCenterToCenter" | "ShapeSlotOverall" => {
|
||||
let dir = (p[1].0 - p[0].0).normalized().unwrap_or(Vec2::X);
|
||||
let w = 2.0 * dir.cross(p[2].0 - p[0].0).abs();
|
||||
json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "width": w.max(1e-3)})
|
||||
}
|
||||
"DrawPoint" => json!({"point": xy(&p[0])}),
|
||||
_ => return,
|
||||
};
|
||||
let _ = app.run(t.cmd, params);
|
||||
}
|
||||
|
||||
fn run_pick(app: &mut SolveApp, t: &Tool) {
|
||||
let k = &t.picks;
|
||||
let params = match t.cmd {
|
||||
"ConstraintHorizontalVertical" => json!({"line": k[0]}),
|
||||
"ConstraintFix" => json!({"entity": k[0]}),
|
||||
"sketch.construction" => json!({"curves": [k[0]]}),
|
||||
"ConstraintCoincident" => json!({"a": k[0], "b": k[1]}),
|
||||
"ConstraintMidPoint" => json!({"point": k[0], "line": k[1]}),
|
||||
"ConstraintSymmetry" => json!({"a": k[0], "b": k[1], "line": k[2]}),
|
||||
_ => json!({"a": k[0], "b": k[1]}),
|
||||
};
|
||||
let _ = app.run(t.cmd, params);
|
||||
}
|
||||
|
||||
/// Right click / Esc: end the current chain (line) or cancel the tool.
|
||||
pub fn finish(app: &mut SolveApp) {
|
||||
if let Some(t) = app.tool.as_mut()
|
||||
&& !t.pts.is_empty()
|
||||
{
|
||||
t.pts.clear();
|
||||
t.picks.clear();
|
||||
return;
|
||||
}
|
||||
app.tool = None;
|
||||
}
|
||||
|
||||
/// Rubber-band preview of the shape being drawn.
|
||||
pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) {
|
||||
let Some(sid) = app.session.active_sketch else { return };
|
||||
let st = app.session.model.state();
|
||||
let Some(ss) = st.sketch(sid) else { return };
|
||||
let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p));
|
||||
let stroke = Stroke::new(1.5, Color32::from_rgb(30, 120, 230));
|
||||
if let Some((hp, snap)) = &t.hover
|
||||
&& let Some(sp) = to(*hp)
|
||||
{
|
||||
painter.circle_stroke(sp, if snap.is_some() { 6.0 } else { 3.0 }, stroke);
|
||||
painter.text(
|
||||
sp + egui::vec2(10.0, 10.0),
|
||||
egui::Align2::LEFT_TOP,
|
||||
format!("{:.2}, {:.2}", hp.x, hp.y),
|
||||
egui::FontId::proportional(11.0),
|
||||
Color32::from_rgb(60, 70, 90),
|
||||
);
|
||||
}
|
||||
let Some((h, _)) = t.hover.clone() else { return };
|
||||
let pts: Vec<Vec2> = t.pts.iter().map(|p| p.0).collect();
|
||||
let line = |a: Vec2, b: Vec2| {
|
||||
if let (Some(x), Some(y)) = (to(a), to(b)) {
|
||||
painter.line_segment([x, y], stroke);
|
||||
}
|
||||
};
|
||||
let circle = |c: Vec2, r: f64| {
|
||||
let v: Vec<Pos2> = (0..=64).filter_map(|i| to(c + Vec2::from_angle(i as f64 / 64.0 * std::f64::consts::TAU) * r)).collect();
|
||||
painter.add(Shape::line(v, stroke));
|
||||
};
|
||||
match (t.cmd, pts.as_slice()) {
|
||||
("DrawPolyline", [.., last]) => line(*last, h),
|
||||
("ShapeRectangleTwoPoint", [a]) => {
|
||||
let (b, c) = (Vec2::new(h.x, a.y), Vec2::new(a.x, h.y));
|
||||
line(*a, b);
|
||||
line(b, h);
|
||||
line(h, c);
|
||||
line(c, *a);
|
||||
}
|
||||
("ShapeRectangleCenter", [c]) => {
|
||||
let d = h - *c;
|
||||
let k =
|
||||
[Vec2::new(c.x - d.x, c.y - d.y), Vec2::new(c.x + d.x, c.y - d.y), Vec2::new(c.x + d.x, c.y + d.y), Vec2::new(c.x - d.x, c.y + d.y)];
|
||||
for i in 0..4 {
|
||||
line(k[i], k[(i + 1) % 4]);
|
||||
}
|
||||
}
|
||||
("CircleCenterRadius" | "ShapePolygonInscribed" | "ShapePolygonCircumscribed", [c]) => circle(*c, c.dist(h)),
|
||||
("CircleTwoPoint", [a]) => circle((*a + h) * 0.5, a.dist(h) * 0.5),
|
||||
(_, [a]) => line(*a, h),
|
||||
(_, [a, b]) => {
|
||||
line(*a, *b);
|
||||
line(*b, h);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -3,7 +3,8 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
const ASSET_EXT: &[&str] = &["png", "jpg", "jpeg", "svg", "ico", "icns", "ttf", "otf", "woff", "woff2", "gif", "bmp", "webp", "wav", "mp3", "stl", "step", "stp", "obj"];
|
||||
const ASSET_EXT: &[&str] =
|
||||
&["png", "jpg", "jpeg", "svg", "ico", "icns", "ttf", "otf", "woff", "woff2", "gif", "bmp", "webp", "wav", "mp3", "stl", "step", "stp", "obj"];
|
||||
const SKIP_DIRS: &[&str] = &["target", ".git", "plan", "dist"];
|
||||
|
||||
fn walk(dir: &Path, root: &Path, out: &mut Vec<String>) {
|
||||
|
||||
+2
-1
@@ -127,7 +127,8 @@ fn cmd_oracle() -> Result<(), String> {
|
||||
cmd.arg(c);
|
||||
}
|
||||
let out = cmd.output().map_err(|e| format!("solvecraft-cli: {e}"))?;
|
||||
let results: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("oracle JSON: {e}\n{}", String::from_utf8_lossy(&out.stderr)))?;
|
||||
let results: serde_json::Value =
|
||||
serde_json::from_slice(&out.stdout).map_err(|e| format!("oracle JSON: {e}\n{}", String::from_utf8_lossy(&out.stderr)))?;
|
||||
let list = results.as_array().cloned().unwrap_or_default();
|
||||
let pass = list.iter().filter(|r| r["pass"].as_bool().unwrap_or(false)).count();
|
||||
let skipped = list.iter().filter(|r| r["skipped"].as_bool().unwrap_or(false)).count();
|
||||
|
||||
+10
-3
@@ -93,7 +93,9 @@ pub fn run(root: &Path, refresh: bool) -> Result<(), String> {
|
||||
let s = std::fs::read_to_string(&mt).map_err(|e| format!("{}: {e}", mt.display()))?;
|
||||
let v: Value = serde_json::from_str(&s).map_err(|e| format!("menu-tree.json: {e}"))?;
|
||||
let entries = from_menu_tree(&v);
|
||||
let mut out = String::from("# Fusion Design workspace toolbar: tab, panel, command id, command name (ids and names only).\n# Regenerate with `cargo xtask parity --refresh` (needs the local plan/fusion/menu-tree.json).\n");
|
||||
let mut out = String::from(
|
||||
"# Fusion Design workspace toolbar: tab, panel, command id, command name (ids and names only).\n# Regenerate with `cargo xtask parity --refresh` (needs the local plan/fusion/menu-tree.json).\n",
|
||||
);
|
||||
for e in &entries {
|
||||
out += &format!("{}\t{}\t{}\t{}\n", e.tab, e.panel, e.id, e.name);
|
||||
}
|
||||
@@ -109,7 +111,7 @@ pub fn run(root: &Path, refresh: bool) -> Result<(), String> {
|
||||
.map_err(|e| format!("solvecraft-cli: {e}"))?;
|
||||
let cmds: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("commands JSON: {e}"))?;
|
||||
let ids: Vec<String> = cmds.as_array().into_iter().flatten().filter_map(|c| c["id"].as_str().map(str::to_string)).collect();
|
||||
let live = |e: &Entry| ids.iter().any(|i| *i == e.id);
|
||||
let live = |e: &Entry| ids.contains(&e.id);
|
||||
let pct = |a: usize, b: usize| if b == 0 { 0.0 } else { 100.0 * a as f64 / b as f64 };
|
||||
|
||||
let head: Vec<&Entry> = catalog.iter().filter(|e| HEADLINE.contains(&e.tab.as_str())).collect();
|
||||
@@ -146,7 +148,12 @@ pub fn run(root: &Path, refresh: bool) -> Result<(), String> {
|
||||
md += &format!("- {} › {} › {} (`{}`)\n", e.tab, e.panel, e.name, e.id);
|
||||
}
|
||||
std::fs::write(root.join("docs/parity.md"), md).map_err(|e| format!("docs/parity.md: {e}"))?;
|
||||
println!("parity: SOLID+SKETCH {head_live}/{} ({:.0}%), all tabs {all_live}/{} → docs/parity.md", head.len(), pct(head_live, head.len()), catalog.len());
|
||||
println!(
|
||||
"parity: SOLID+SKETCH {head_live}/{} ({:.0}%), all tabs {all_live}/{} → docs/parity.md",
|
||||
head.len(),
|
||||
pct(head_live, head.len()),
|
||||
catalog.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user