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:
bherbruckandClaude Opus 5.5 committed 2026-10-07 17:54:58 +00:00
1 parent 3714342737
commit 8a811f8eff
33 files changed
+7321 -32

No files matched your search

Generated
+3378 -16
View File
File diff suppressed because it is too large. Load diff
+15
View File
@@ -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 }
+62
View File
@@ -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
View File
@@ -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)))
}),
)
}
+1 -1
View File
@@ -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()
}
+3 -1
View File
@@ -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);
}
}
}
+1
View File
@@ -10,6 +10,7 @@
pub mod cmd;
pub mod params;
pub mod sample;
pub mod view;
use std::sync::Arc;
+33
View File
@@ -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"}},
]})
}
+13
View File
@@ -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);
}
+4 -1
View File
@@ -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 });
}
}
+3
View File
@@ -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.
+1 -1
View File
@@ -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]
+7
View File
@@ -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)?;
+1 -1
View File
@@ -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 {
+48
View File
@@ -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;
+1
View File
@@ -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 };
+14 -5
View File
@@ -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;
}
}
+10
View File
@@ -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 }
+116
View File
@@ -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");
}
});
});
});
});
}
+210
View File
@@ -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()}),
}
}
+454
View File
@@ -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(()),
}
}
+329
View File
@@ -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()
}
+365
View File
@@ -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);
}
}
}
+321
View File
@@ -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, &params).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)
}
+133
View File
@@ -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();
}
}
+70
View File
@@ -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);
});
}
+129
View File
@@ -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;
}
}
});
}
+374
View File
@@ -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);
}
}
}
}
}
+281
View File
@@ -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);
}
_ => {}
}
}
+821
View File
@@ -0,0 +1,821 @@
//! The 3D viewport: navigation, GPU (or CPU fallback) rendering, picking, overlays, the view
//! cube and the navigation bar.
use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2};
use serde_json::json;
use solvecraft_engine::Sel;
use solvecraft_engine::geom::{Vec2, Vec3};
use solvecraft_engine::render::{Camera, Mat4, Rgb, StandardView};
use solvecraft_engine::sketch::{ConstraintKind, CurveKind};
use solvecraft_engine::view::{colors, grid_step, sketch_consumed, sketch_lines};
use std::hash::{Hash, Hasher};
use crate::gpu::{GpuScene, GpuTarget, SceneSlot, ViewportCallback};
use crate::theme::Tokens;
use crate::{SolveApp, icons};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NavMode {
Orbit,
Pan,
Zoom,
}
#[derive(Default)]
pub struct ViewportState {
pub rect: Option<Rect>,
pub gpu: Option<GpuTarget>,
slot: SceneSlot,
key: u64,
cpu: Option<(u64, egui::TextureHandle)>,
pub nav: Option<NavMode>,
pub hover: Option<Hit>,
pub mouse: Option<Pos2>,
pub build_ms: f64,
}
/// Something under the cursor.
#[derive(Clone, Debug, PartialEq)]
pub enum Hit {
SketchPoint { sketch: u64, id: String, at: Vec2 },
SketchCurve { sketch: u64, id: String },
Edge { body: String, index: usize, mid: Vec3 },
Profile { sketch: u64, index: usize },
Face { body: String, index: usize, point: Vec3 },
}
/// World ↔ screen mapping for the current frame.
#[derive(Clone, Copy)]
pub struct Proj {
pub cam: Camera,
pub vp: Mat4,
pub rect: Rect,
}
impl Proj {
pub fn to_screen(&self, p: Vec3) -> Option<Pos2> {
let c = self.vp.apply(p);
if c[3] <= 1e-9 {
return None;
}
let (x, y) = (c[0] / c[3], c[1] / c[3]);
Some(pos2(
self.rect.left() + ((x + 1.0) * 0.5 * self.rect.width() as f64) as f32,
self.rect.top() + ((1.0 - y) * 0.5 * self.rect.height() as f64) as f32,
))
}
pub fn ray(&self, p: Pos2) -> (Vec3, Vec3) {
self.cam.ray((p.x - self.rect.left()) as f64, (p.y - self.rect.top()) as f64, self.rect.width() as f64, self.rect.height() as f64)
}
}
fn rgba(c: Rgb) -> [u8; 4] {
[c.0, c.1, c.2, 255]
}
pub fn scene_radius(app: &SolveApp) -> f64 {
let b = solvecraft_engine::view::bounds(&app.session);
let r = if b.is_empty() { 50.0 } else { b.diagonal() * 0.5 + b.center().dist(app.cam.target) };
r.max(app.cam.half_height() * 2.0).max(10.0)
}
pub fn projection(app: &SolveApp, rect: Rect) -> Proj {
let mut cam = app.cam;
cam.fov = if app.ui.perspective { 0.6 } else { 0.0 };
let aspect = rect.width() as f64 / rect.height().max(1.0) as f64;
Proj { cam, vp: cam.view_proj(aspect, scene_radius(app)), rect }
}
/// Sketches drawn in the viewport: the active one, and finished ones not yet used by a feature.
fn visible_sketches(app: &SolveApp) -> Vec<u64> {
let st = app.session.model.state();
st.sketches
.iter()
.filter(|s| app.session.active_sketch == Some(s.feature) || (app.ui.show_sketches && !sketch_consumed(&app.session, s.feature)))
.map(|s| s.feature)
.collect()
}
fn scene_key(app: &SolveApp) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
app.session.revision.hash(&mut h);
serde_json::to_string(&app.session.selection).unwrap_or_default().hash(&mut h);
serde_json::to_string(&app.ui).unwrap_or_default().hash(&mut h);
app.session.active_sketch.hash(&mut h);
let (minor, _) = grid_step(app.cam.half_height());
minor.to_bits().hash(&mut h);
((app.cam.target.x / (minor * 5.0)).round() as i64).hash(&mut h);
((app.cam.target.y / (minor * 5.0)).round() as i64).hash(&mut h);
if let Some(d) = &app.dialog {
d.highlight_key().hash(&mut h);
}
h.finish()
}
fn build_scene(app: &SolveApp) -> GpuScene {
let mut sc = GpuScene::default();
let s = &app.session;
let st = s.model.state();
if app.ui.show_grid {
let (minor, major) = grid_step(app.cam.half_height());
let ext = (app.cam.half_height() * 3.0 / major).ceil() * major;
let c = Vec3::new((app.cam.target.x / major).round() * major, (app.cam.target.y / major).round() * major, 0.0);
let n = ((ext / minor) as i64).clamp(1, 300);
for i in -n..=n {
let t = i as f64 * minor;
let maj = (t / major).round() * major == t;
let col = if maj { rgba(colors::GRID_MAJOR) } else { rgba(colors::GRID) };
let w = if maj { 1.0 } else { 0.7 };
sc.line((c + Vec3::new(t, -ext, 0.0)).to_f32(), (c + Vec3::new(t, ext, 0.0)).to_f32(), col, w, false);
sc.line((c + Vec3::new(-ext, t, 0.0)).to_f32(), (c + Vec3::new(ext, t, 0.0)).to_f32(), col, w, false);
}
}
if app.ui.show_origin {
let a = app.cam.half_height() * 0.3;
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
sc.line([0.0; 3], (d * a).to_f32(), rgba(col), 2.0, false);
}
}
let dialog_edges: Vec<Vec3> = app.dialog.as_ref().map(|d| d.edges()).unwrap_or_default();
for b in &st.bodies {
if app.ui.hidden_bodies.contains(&b.name) {
continue;
}
let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name));
let col = rgba(if selected { colors::BODY_SELECTED } else { colors::BODY });
let m = b.mesh();
for t in &m.triangles {
for k in t {
let i = *k as usize;
if let (Some(p), Some(n)) = (m.positions.get(i), m.normals.get(i)) {
sc.tri(p.to_f32(), n.to_f32(), col);
}
}
}
for (ei, e) in m.edges.iter().enumerate() {
if m.seams.get(ei).copied().unwrap_or(false) {
continue;
}
let sel = s.selection.iter().any(|x| matches!(x, Sel::Edge { body, index, .. } if *body == b.name && *index == ei))
|| dialog_edges.iter().any(|p| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) < 1e-6 * b.body.size().max(1.0) + 1e-6));
for w in e.windows(2) {
if sel {
sc.line(w[0].to_f32(), w[1].to_f32(), [30, 120, 230, 255], 3.5, true);
} else {
sc.line(w[0].to_f32(), w[1].to_f32(), rgba(colors::EDGE), 1.3, false);
}
}
}
}
let dialog_profiles = app.dialog.as_ref().map(|d| d.profiles()).unwrap_or_default();
for sid in visible_sketches(app) {
let Some(ss) = st.sketch(sid) else { continue };
let active = s.active_sketch == Some(sid);
for (pts, col, cons) in sketch_lines(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) {
for w in pts.windows(2) {
sc.line(w[0].to_f32(), w[1].to_f32(), rgba(col), if cons { 1.2 } else { 2.0 }, active);
}
}
// Selected profiles: outline.
for (pi, p) in ss.profiles.iter().enumerate() {
let picked = dialog_profiles.contains(&(sid, pi))
|| s.selection.iter().any(|x| matches!(x, Sel::Profile { sketch, index } if *sketch == sid && *index == pi));
if !picked {
continue;
}
for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) {
let poly = lp.polyline(0.02);
let n = poly.len();
for i in 0..n {
if let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) {
sc.line(ss.plane.to_world(*a).to_f32(), ss.plane.to_world(*b).to_f32(), rgba(colors::PROFILE), 4.0, true);
}
}
}
}
}
sc
}
/// Pick what is under `pos`, most specific first.
pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
let s = &app.session;
let st = s.model.state();
let mut hits = Vec::new();
let (o, d) = proj.ray(pos);
// Sketch points and curves of the active sketch.
if let Some(sid) = s.active_sketch
&& let Some(ss) = st.sketch(sid)
{
let mut best: Option<(f32, Hit)> = None;
for p in &ss.sketch.points {
if let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) {
let dd = sp.distance(pos);
if dd < 8.0 && best.as_ref().is_none_or(|(b, _)| dd < *b) {
best = Some((dd, Hit::SketchPoint { sketch: sid, id: p.id.clone(), at: p.pos }));
}
}
}
if let Some((_, h)) = best {
hits.push(h);
}
let mut bestc: Option<(f32, Hit)> = None;
for (i, c) in ss.sketch.curves.iter().enumerate() {
for seg in ss.sketch.segs(i) {
let pts: Vec<Pos2> = seg.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect();
for w in pts.windows(2) {
let dd = seg_dist(pos, w[0], w[1]);
if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) {
bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: c.id.clone() }));
}
}
}
}
if let Some((_, h)) = bestc {
hits.push(h);
}
}
// Edges.
let mut beste: Option<(f32, Hit)> = None;
for b in &st.bodies {
if app.ui.hidden_bodies.contains(&b.name) {
continue;
}
let m = b.mesh();
for (ei, e) in m.edges.iter().enumerate() {
if m.seams.get(ei).copied().unwrap_or(false) {
continue;
}
let pts: Vec<Pos2> = e.iter().filter_map(|q| proj.to_screen(*q)).collect();
for w in pts.windows(2) {
let dd = seg_dist(pos, w[0], w[1]);
if dd < 6.0 && beste.as_ref().is_none_or(|(bd, _)| dd < *bd) {
beste = Some((dd, Hit::Edge { body: b.name.clone(), index: ei, mid: polyline_mid(e) }));
}
}
}
}
// Faces (nearest along the ray), used to reject edges hidden behind faces.
let mut bestf: Option<(f64, Hit)> = None;
for b in &st.bodies {
if app.ui.hidden_bodies.contains(&b.name) {
continue;
}
let m = b.mesh();
if let Some((t, ti)) = m.raycast(o, d)
&& bestf.as_ref().is_none_or(|(bt, _)| t < *bt)
{
let fi = m.tri_face.get(ti).copied().unwrap_or(0) as usize;
bestf = Some((t, Hit::Face { body: b.name.clone(), index: fi, point: o + d * t }));
}
}
if let Some((_, h)) = beste {
// An edge counts only when it is not hidden behind the first face along the ray.
let visible = match (&h, &bestf) {
(Hit::Edge { mid, .. }, Some((t, _))) => (*mid - o).dot(d) <= *t + scene_radius(app) * 0.05,
_ => true,
};
if visible {
hits.push(h);
}
}
// Profiles of visible sketches.
let mut bestp: Option<(f64, Hit)> = None;
for sid in visible_sketches(app) {
let Some(ss) = st.sketch(sid) else { continue };
let Some(w) = ss.plane.intersect_ray(o, d) else { continue };
let lp = ss.plane.to_local(w);
for (pi, p) in ss.profiles.iter().enumerate() {
if p.region.contains(lp) && bestp.as_ref().is_none_or(|(a, _)| p.area < *a) {
bestp = Some((p.area, Hit::Profile { sketch: sid, index: pi }));
}
}
}
if let Some((_, h)) = bestp {
hits.push(h);
}
if let Some((_, h)) = bestf {
hits.push(h);
}
hits
}
fn polyline_mid(pts: &[Vec3]) -> Vec3 {
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
let mut acc = 0.0;
for w in pts.windows(2) {
let l = w[0].dist(w[1]);
if acc + l >= len * 0.5 && l > 0.0 {
return w[0].lerp(w[1], (len * 0.5 - acc) / l);
}
acc += l;
}
pts.first().copied().unwrap_or_default()
}
fn seg_dist(p: Pos2, a: Pos2, b: Pos2) -> f32 {
let ab = b - a;
let l2 = ab.length_sq();
let t = if l2 > 0.0 { ((p - a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
p.distance(a + ab * t)
}
/// Point on the active sketch plane under the cursor (sketch coordinates), snapped to sketch
/// points within a few pixels and otherwise to a fine grid. Returns the snapped point and the
/// id of the sketch point it snapped to.
pub fn sketch_point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec2, Option<String>)> {
let sid = app.session.active_sketch?;
let st = app.session.model.state();
let ss = st.sketch(sid)?;
for h in pick(app, proj, pos) {
if let Hit::SketchPoint { id, at, .. } = h {
return Some((at, Some(id)));
}
}
let (o, d) = proj.ray(pos);
let w = ss.plane.intersect_ray(o, d)?;
let lp = ss.plane.to_local(w);
let (minor, _) = grid_step(app.cam.half_height());
let step = minor / 10.0;
Some((Vec2::new((lp.x / step).round() * step, (lp.y / step).round() * step), None))
}
pub fn delete_selection(app: &mut SolveApp) {
let sel = app.session.selection.clone();
if sel.is_empty() {
return;
}
let mut sketch_ids: Vec<String> = Vec::new();
let mut features: Vec<u64> = Vec::new();
for x in &sel {
match x {
Sel::SketchCurve { id } | Sel::SketchPoint { id } => sketch_ids.push(id.clone()),
Sel::Feature { id } => features.push(*id),
_ => {}
}
}
if !sketch_ids.is_empty() && app.session.active_sketch.is_some() {
let _ = app.run("sketch.delete", json!({"entities": sketch_ids}));
}
if !features.is_empty() {
let _ = app.run("FusionDeleteCommand", json!({"features": features.iter().map(|f| f.to_string()).collect::<Vec<_>>()}));
}
let _ = app.run("select.clear", json!({}));
}
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let rect = ui.max_rect();
app.viewport.rect = Some(rect);
let resp = ui.interact(rect, ui.id().with("viewport"), Sense::click_and_drag());
let (hover, scroll, mods, middle, secondary_down, primary_down, delta) = ui.input(|i| {
(
i.pointer.hover_pos(),
i.smooth_scroll_delta.y,
i.modifiers,
i.pointer.middle_down(),
i.pointer.secondary_down(),
i.pointer.primary_down(),
i.pointer.delta(),
)
});
let inside = hover.is_some_and(|p| rect.contains(p));
app.viewport.mouse = hover.filter(|_| inside);
// ---- navigation ----
let h = rect.height().max(1.0) as f64;
if inside && scroll != 0.0 {
let f = (-scroll as f64 * 0.0015).exp();
let anchor = hover.map(|p| {
let proj = projection(app, rect);
let (o, d) = proj.ray(p);
let n = app.cam.back();
let den = d.dot(n);
if den.abs() > 1e-9 { o + d * ((app.cam.target - o).dot(n) / den) } else { app.cam.target }
});
app.cam.zoom_at(f, anchor);
}
let dragging = resp.dragged() || (inside && (middle || secondary_down));
if dragging && delta != egui::Vec2::ZERO {
let (dx, dy) = (delta.x as f64, delta.y as f64);
let mode = if middle && (mods.shift) || (secondary_down && !middle) {
Some(NavMode::Orbit)
} else if middle {
Some(NavMode::Pan)
} else if primary_down {
app.viewport.nav
} else {
None
};
match mode {
Some(NavMode::Orbit) => app.cam.orbit(dx, dy),
Some(NavMode::Pan) => app.cam.pan(dx, dy, h),
Some(NavMode::Zoom) => app.cam.zoom_at((dy * 0.01).exp(), None),
None => {}
}
}
if !app.cam.is_valid() {
app.cam = Camera::default();
app.fit_view();
}
let proj = projection(app, rect);
let painter = ui.painter_at(rect);
// Background gradient.
let top = Color32::from_rgb(colors::BG_TOP.0, colors::BG_TOP.1, colors::BG_TOP.2);
let bot = Color32::from_rgb(colors::BG_BOTTOM.0, colors::BG_BOTTOM.1, colors::BG_BOTTOM.2);
let mut mesh = egui::Mesh::default();
mesh.colored_vertex(rect.left_top(), top);
mesh.colored_vertex(rect.right_top(), top);
mesh.colored_vertex(rect.right_bottom(), bot);
mesh.colored_vertex(rect.left_bottom(), bot);
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
painter.add(Shape::mesh(mesh));
// ---- model ----
let key = scene_key(app);
let t0 = crate::now_ms();
if let Some(_g) = app.viewport.gpu {
if key != app.viewport.key {
let sc = build_scene(app);
if let Ok(mut slot) = app.viewport.slot.lock() {
*slot = Some(sc);
}
app.viewport.key = key;
app.viewport.build_ms = crate::now_ms() - t0;
}
let ppp = ui.ctx().pixels_per_point();
let cb = ViewportCallback {
key: app.viewport.key,
slot: app.viewport.slot.clone(),
view_proj: proj.vp.to_f32(),
back: proj.cam.back().to_f32(),
size_px: [rect.width() * ppp, rect.height() * ppp],
};
painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb));
} else {
cpu_render(app, ui.ctx(), &painter, rect, &proj);
}
// ---- interaction ----
let hits = hover.filter(|_| inside).map(|p| pick(app, &proj, p)).unwrap_or_default();
app.viewport.hover = hits.first().cloned();
if let Some(p) = hover.filter(|_| inside) {
if app.tool.is_some() {
crate::tools::on_hover(app, &proj, p);
}
if resp.clicked() {
if app.tool.is_some() {
crate::tools::on_click(app, &proj, p);
} else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) {
if let Some(d) = app.dialog.as_mut() {
d.on_pick(&hits);
}
} else {
select(app, &hits, mods.shift || mods.command);
}
}
if resp.secondary_clicked() && delta == egui::Vec2::ZERO && app.tool.is_some() {
crate::tools::finish(app);
}
}
overlays(app, ui, &painter, &proj);
hover_highlight(app, &painter, &proj);
if let Some(tl) = app.tool.as_ref() {
crate::tools::preview(app, tl, &painter, &proj);
}
view_cube(app, ui, rect);
nav_bar(app, ui, rect);
// Status chip.
if let Some((msg, at, err)) = app.status.clone() {
if crate::now_ms() - at < 6000.0 {
let pos = pos2(rect.left() + 12.0, rect.bottom() - 46.0);
let galley = painter.layout_no_wrap(msg, FontId::proportional(12.5), if err { t.error } else { t.text });
let r = Rect::from_min_size(pos, galley.size() + vec2(16.0, 8.0));
painter.rect_filled(r, 4.0, Color32::from_white_alpha(230));
painter.galley(pos + vec2(8.0, 4.0), galley, t.text);
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
} else {
app.status = None;
}
}
}
fn select(app: &mut SolveApp, hits: &[Hit], add: bool) {
let item = hits.first().map(|h| match h {
Hit::SketchPoint { id, .. } => json!({"type": "sketch_point", "id": id}),
Hit::SketchCurve { id, .. } => json!({"type": "sketch_curve", "id": id}),
Hit::Edge { body, index, mid } => json!({"type": "edge", "body": body, "index": index, "point": mid}),
Hit::Profile { sketch, index } => json!({"type": "profile", "sketch": sketch, "index": index}),
Hit::Face { body, .. } => json!({"type": "body", "name": body}),
});
match item {
Some(i) => {
let _ = app.run("select.set", json!({"items": [i], "add": add}));
}
None => {
let _ = app.run("select.clear", json!({}));
}
}
}
fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
let col = Color32::from_rgb(40, 140, 255);
let st = app.session.model.state();
match &app.viewport.hover {
Some(Hit::Edge { body, index, .. }) => {
if let Some(b) = st.body(body)
&& let Some(e) = b.mesh().edges.get(*index)
{
let pts: Vec<Pos2> = e.iter().filter_map(|q| proj.to_screen(*q)).collect();
painter.add(Shape::line(pts, Stroke::new(3.0, col)));
}
}
Some(Hit::SketchCurve { sketch, id }) => {
if let Some(ss) = st.sketch(*sketch)
&& let Some(ci) = ss.sketch.curve_index(id)
{
for seg in ss.sketch.segs(ci) {
let pts: Vec<Pos2> = seg.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect();
painter.add(Shape::line(pts, Stroke::new(3.0, col)));
}
}
}
Some(Hit::SketchPoint { sketch, at, .. }) => {
if let Some(ss) = st.sketch(*sketch)
&& let Some(p) = proj.to_screen(ss.plane.to_world(*at))
{
painter.circle_stroke(p, 6.0, Stroke::new(2.0, col));
}
}
Some(Hit::Profile { sketch, index }) if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) => {
if let Some(ss) = st.sketch(*sketch)
&& let Some(p) = ss.profiles.get(*index)
{
let pts: Vec<Pos2> = p.region.outer.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect();
painter.add(Shape::closed_line(pts, Stroke::new(2.0, Color32::from_rgb(255, 170, 60))));
}
}
_ => {}
}
}
/// Sketch points, dimension labels and constraint glyphs of the active sketch.
fn overlays(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj) {
let t = Tokens::get();
let st = app.session.model.state();
let Some(sid) = app.session.active_sketch else { return };
let Some(ss) = st.sketch(sid) else { return };
let sk = &ss.sketch;
for (i, p) in sk.points.iter().enumerate() {
let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) else { continue };
let det = ss.report.point_determined.get(i).copied().unwrap_or(false);
let c = if det { Color32::BLACK } else { Color32::from_rgb(30, 90, 200) };
if i == 0 {
painter.circle(sp, 3.5, Color32::from_rgb(240, 200, 60), Stroke::new(1.0, Color32::BLACK));
} else {
painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c);
}
}
let mut edit: Option<String> = None;
for c in &sk.constraints {
let Some(param) = &c.param else {
glyph(painter, proj, ss, &c.kind);
continue;
};
let expr = app.session.doc.param(param).map(|p| p.expr.clone()).unwrap_or_default();
let anchor = dim_anchor(sk, &c.kind);
let Some(a) = anchor.and_then(|a| proj.to_screen(ss.plane.to_world(a))) else { continue };
let text = if expr.chars().all(|ch| ch.is_ascii_digit() || ch == '.' || ch == ' ' || ch == 'm' || ch == 'd' || ch == 'e' || ch == 'g') {
expr.replace(" mm", "").replace(" deg", "°")
} else {
format!("{param}: {}", expr)
};
let galley = painter.layout_no_wrap(text, FontId::proportional(12.0), t.text);
let r = Rect::from_center_size(a, galley.size() + vec2(8.0, 4.0));
painter.rect(r, 2.0, Color32::from_white_alpha(235), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
painter.galley(r.min + vec2(4.0, 2.0), galley, t.text);
let resp = ui.interact(r, ui.id().with(("dim", param.as_str())), Sense::click());
if resp.double_clicked() || resp.clicked() && app.tool.is_none() {
edit = Some(param.clone());
}
}
if let Some(p) = edit {
app.dialog = Some(crate::dialogs::Dialog::edit_param(&app.session, &p));
}
// Sketch status.
if let Some(r) = app.viewport.rect {
let msg = if !ss.report.ok() {
format!("{} — constraints conflict", ss.name)
} else if ss.report.dof == 0 {
format!("{} — fully constrained", ss.name)
} else {
format!("{} — {} degrees of freedom", ss.name, ss.report.dof)
};
let c = if ss.report.ok() { t.sketch_accent } else { t.error };
painter.text(pos2(r.left() + 12.0, r.top() + 12.0), Align2::LEFT_TOP, msg, FontId::proportional(13.0), c);
}
}
fn line_mid(sk: &solvecraft_engine::sketch::Sketch, l: usize) -> Option<(Vec2, Vec2)> {
match sk.curves.get(l)?.kind {
CurveKind::Line { a, b } => Some((sk.point(a)?, sk.point(b)?)),
_ => None,
}
}
/// Where a dimension label goes (sketch coordinates).
fn dim_anchor(sk: &solvecraft_engine::sketch::Sketch, k: &ConstraintKind) -> Option<Vec2> {
use ConstraintKind::*;
match *k {
Length { l, .. } | PointLineDistance { l, .. } => {
let (a, b) = line_mid(sk, l)?;
let n = (b - a).perp().normalized().unwrap_or(Vec2::Y);
Some((a + b) * 0.5 + n * ((b - a).len() * 0.08 + 1.0))
}
Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => Some((sk.point(p)? + sk.point(q)?) * 0.5),
Radius { c, .. } | Diameter { c, .. } => {
let r = sk.radius(c)?;
Some(sk.center(c)? + Vec2::new(std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2) * r)
}
Angle { a, b, .. } => {
let (a0, a1) = line_mid(sk, a)?;
let (b0, b1) = line_mid(sk, b)?;
Some((a0 + a1 + b0 + b1) * 0.25)
}
_ => None,
}
}
/// Small constraint markers next to the constrained geometry.
fn glyph(painter: &egui::Painter, proj: &Proj, ss: &solvecraft_engine::doc::SolvedSketch, k: &ConstraintKind) {
use ConstraintKind::*;
let sk = &ss.sketch;
let (anchor, label) = match *k {
Horizontal { l } => (line_mid(sk, l).map(|(a, b)| (a + b) * 0.5), "H"),
Vertical { l } => (line_mid(sk, l).map(|(a, b)| (a + b) * 0.5), "V"),
Parallel { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5), "//"),
Perpendicular { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5), "L"),
Tangent { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5).or_else(|| sk.center(a)), "T"),
Equal { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5).or_else(|| sk.center(a)), "="),
Fix { p } => (sk.point(p), "F"),
_ => (None, ""),
};
let Some(a) = anchor.and_then(|a| proj.to_screen(ss.plane.to_world(a))) else { return };
let r = Rect::from_center_size(a + vec2(10.0, -10.0), vec2(13.0, 13.0));
painter.rect_filled(r, 2.0, Color32::from_rgb(255, 255, 255));
painter.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_rgb(90, 150, 90)), egui::StrokeKind::Inside);
painter.text(r.center(), Align2::CENTER_CENTER, label, FontId::proportional(10.0), Color32::from_rgb(40, 110, 40));
}
fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, rect: Rect, proj: &Proj) {
let mut h = std::collections::hash_map::DefaultHasher::new();
scene_key(app).hash(&mut h);
for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] {
v.to_bits().hash(&mut h);
}
(rect.width() as u32, rect.height() as u32).hash(&mut h);
let key = h.finish();
if app.viewport.cpu.as_ref().is_none_or(|(k, _)| *k != key) {
let scene = solvecraft_engine::view::scene(&app.session, &proj.cam);
let w = (rect.width() as usize).clamp(16, 4096);
let hh = (rect.height() as usize).clamp(16, 4096);
let c = solvecraft_engine::render::render(&scene, &proj.cam, w, hh);
let img = egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba);
let tex = ctx.load_texture("sc_cpu_viewport", img, egui::TextureOptions::LINEAR);
app.viewport.cpu = Some((key, tex));
}
if let Some((_, tex)) = &app.viewport.cpu {
painter.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
}
/// The view cube (top right): click a face for a standard view; the house resets.
fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let t = Tokens::get();
let c = pos2(rect.right() - 80.0, rect.top() + 80.0);
let s = 32.0f64;
let (r, u, b) = app.cam.basis();
let to2 = |p: Vec3| -> (Pos2, f64) { (pos2(c.x + (p.dot(r) * s) as f32, c.y - (p.dot(u) * s) as f32), p.dot(b)) };
let faces: [(Vec3, &str, StandardView); 6] = [
(Vec3::new(0.0, -1.0, 0.0), "FRONT", StandardView::Front),
(Vec3::new(0.0, 1.0, 0.0), "BACK", StandardView::Back),
(Vec3::new(1.0, 0.0, 0.0), "RIGHT", StandardView::Right),
(Vec3::new(-1.0, 0.0, 0.0), "LEFT", StandardView::Left),
(Vec3::Z, "TOP", StandardView::Top),
(Vec3::new(0.0, 0.0, -1.0), "BOTTOM", StandardView::Bottom),
];
let painter = ui.painter_at(rect);
let mut clicked: Option<StandardView> = None;
let hover = ui.input(|i| i.pointer.hover_pos());
let mut order: Vec<usize> = (0..6).collect();
order.sort_by(|a, b2| faces[*a].0.dot(b).total_cmp(&faces[*b2].0.dot(b)));
for i in order {
let (n, label, v) = faces[i];
if n.dot(b) <= 1e-3 {
continue;
}
let (e1, e2) = if n.z.abs() > 0.5 {
(Vec3::X, Vec3::Y)
} else if n.x.abs() > 0.5 {
(Vec3::Y, Vec3::Z)
} else {
(Vec3::X, Vec3::Z)
};
let corners = [n - e1 - e2, n + e1 - e2, n + e1 + e2, n - e1 + e2];
let pts: Vec<Pos2> = corners.iter().map(|p| to2(*p).0).collect();
let poly_hover = hover.is_some_and(|h| point_in_poly(h, &pts));
let shade = (0.75 + 0.25 * n.dot(b)) as f32;
let fill = if poly_hover { t.accent_soft } else { Color32::from_gray((236.0 * shade) as u8) };
painter.add(Shape::convex_polygon(pts.clone(), fill, Stroke::new(1.0, Color32::from_rgb(140, 148, 160))));
let center = to2(n).0;
if n.dot(b) > 0.35 {
painter.text(center, Align2::CENTER_CENTER, label, FontId::proportional(9.5), t.text);
}
if poly_hover && ui.input(|i| i.pointer.primary_clicked()) {
clicked = Some(v);
}
}
// Axis triad at the cube's corner.
let o = Vec3::new(-1.0, -1.0, -1.0);
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
let (a, _) = to2(o);
let (bb, _) = to2(o + d * 2.6);
painter.line_segment([a, bb], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2)));
}
let home = Rect::from_center_size(pos2(c.x - 52.0, c.y - 48.0), vec2(18.0, 18.0));
let hr = ui.interact(home, ui.id().with("vc_home"), Sense::click());
icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { Color32::WHITE }, t.accent);
if hr.on_hover_text("Home view").clicked() {
app.cam.set_view(StandardView::Iso);
app.fit_view();
}
if let Some(v) = clicked {
app.cam.set_view(v);
}
}
fn point_in_poly(p: Pos2, poly: &[Pos2]) -> bool {
let n = poly.len();
let mut inside = false;
for i in 0..n {
let (a, b) = (poly[i], poly[(i + 1) % n]);
if (a.y > p.y) != (b.y > p.y) {
let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x);
if p.x < x {
inside = !inside;
}
}
}
inside
}
/// Navigation bar (bottom centre).
fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let t = Tokens::get();
let items: [(&str, &str); 7] = [
("orbit", "Orbit (drag; or Shift+middle / right drag)"),
("pan", "Pan (drag; or middle drag)"),
("zoom", "Zoom (drag; or wheel)"),
("fit", "Fit"),
("home", "Home view"),
("perspective", "Perspective / orthographic"),
("settings", "Grid on/off"),
];
let w = items.len() as f32 * 30.0 + 10.0;
let bar = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0));
let painter = ui.painter_at(rect);
painter.rect(bar, 6.0, Color32::from_white_alpha(235), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
for (i, (icon, tip)) in items.iter().enumerate() {
let br = Rect::from_min_size(pos2(bar.left() + 5.0 + i as f32 * 30.0, bar.top() + 2.0), vec2(26.0, 26.0));
let resp = ui.interact(br, ui.id().with(("nav", *icon)), Sense::click());
let active = match *icon {
"orbit" => app.viewport.nav == Some(NavMode::Orbit),
"pan" => app.viewport.nav == Some(NavMode::Pan),
"zoom" => app.viewport.nav == Some(NavMode::Zoom),
"perspective" => app.ui.perspective,
"settings" => app.ui.show_grid,
_ => false,
};
if active || resp.hovered() {
painter.rect_filled(br, 4.0, if active { t.accent_soft } else { t.hover });
}
icons::paint(&painter, br.shrink(4.0), icon, t.icon, t.icon_fill, t.accent);
if resp.on_hover_text(*tip).clicked() {
let toggle = |m: NavMode, cur: Option<NavMode>| if cur == Some(m) { None } else { Some(m) };
match *icon {
"orbit" => app.viewport.nav = toggle(NavMode::Orbit, app.viewport.nav),
"pan" => app.viewport.nav = toggle(NavMode::Pan, app.viewport.nav),
"zoom" => app.viewport.nav = toggle(NavMode::Zoom, app.viewport.nav),
"fit" => app.fit_view(),
"home" => {
app.cam.set_view(StandardView::Iso);
app.fit_view();
}
"perspective" => app.ui.perspective = !app.ui.perspective,
_ => app.ui.show_grid = !app.ui.show_grid,
}
}
}
}
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}