Keyboard-first commands: E/F/H open with the on-canvas value box focused as soon as the geometry is picked (typing replaces the value, Enter applies, Tab moves on, Esc cancels), extrude direction arrow; Extrude takes planar body faces (face: [x,y,z] copies the face boundary into a sketch), so select-face-then-E press-pulls; join/cut previews show the tool body (cut see-through red)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
d44083d1cc
commit
7ef3426cb3
11 files changed
+433
-43
No files matched your search
@@ -0,0 +1,103 @@
|
||||
//! On-canvas command input: while a feature dialog is open, its main value (extrude distance,
|
||||
//! fillet radius…) gets a value box in the viewport next to the geometry, with a direction
|
||||
//! arrow for extrudes. The box takes the keyboard as soon as the command has its geometry, so
|
||||
//! typing a number sets the value at once; Enter applies, Tab moves on, Esc cancels.
|
||||
|
||||
use egui::{Color32, Pos2, Stroke, vec2};
|
||||
use solvecraft_engine::Sel;
|
||||
use solvecraft_engine::doc::expr::Kind as ValueKind;
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{Dialog, Kind};
|
||||
use crate::theme::Tokens;
|
||||
use crate::viewport::Proj;
|
||||
|
||||
/// Where the dialog's geometry is: a base point and, for directional values, the direction a
|
||||
/// positive value points.
|
||||
pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
let st = app.session.model.state();
|
||||
let first = d.inputs.first()?.items.first()?;
|
||||
let at = match first {
|
||||
Sel::Profile { sketch, index } => {
|
||||
let ss = st.sketch(*sketch)?;
|
||||
let p = ss.profiles.get(*index)?;
|
||||
(ss.plane.to_world(p.region.interior_point()), Some(ss.plane.normal()))
|
||||
}
|
||||
Sel::Face { body, index, point } => (*point, crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| n)),
|
||||
Sel::Edge { point, .. } | Sel::Vertex { point, .. } => (*point, None),
|
||||
_ => return None,
|
||||
};
|
||||
// Only extrudes point along the normal.
|
||||
Some(match d.kind {
|
||||
Kind::Extrude { .. } => at,
|
||||
_ => (at.0, None),
|
||||
})
|
||||
}
|
||||
|
||||
/// Draw the arrow and the value box (called by the viewport after the model is drawn).
|
||||
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj) {
|
||||
let Some(mut d) = app.dialog.take() else { return };
|
||||
if let Some((base, dir)) = anchor(app, &d)
|
||||
&& let Some(bs) = proj.to_screen(base)
|
||||
{
|
||||
draw(app, ui, painter, proj, &mut d, base, bs, dir);
|
||||
}
|
||||
app.dialog = Some(d);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog, base: Vec3, bs: Pos2, dir: Option<Vec3>) {
|
||||
let t = Tokens::get();
|
||||
let sign = match d.kind {
|
||||
Kind::Extrude { direction: 1, .. } => -1.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
let symmetric = matches!(d.kind, Kind::Extrude { direction: 2, .. });
|
||||
let focus = std::mem::take(&mut d.focus);
|
||||
let Some((label, kind, value)) = d.primary() else { return };
|
||||
let len = app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite());
|
||||
let mut box_at = bs + vec2(16.0, -30.0);
|
||||
if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) {
|
||||
let n = n * sign;
|
||||
let tip = base + n * l;
|
||||
let back = if symmetric { base - n * l } else { base };
|
||||
if let (Some(ts), Some(b0)) = (proj.to_screen(tip), proj.to_screen(back)) {
|
||||
arrow(painter, b0, ts, t.manipulator);
|
||||
if symmetric && let Some(bt) = proj.to_screen(base - n * l) {
|
||||
arrow(painter, bs, bt, t.manipulator);
|
||||
}
|
||||
painter.circle(bs, 3.5, t.manipulator, Stroke::new(1.0, Color32::WHITE));
|
||||
box_at = ts + vec2(14.0, -12.0);
|
||||
}
|
||||
}
|
||||
let id = egui::Id::new("sc_canvas_value");
|
||||
egui::Area::new(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(label).color(t.text_dim).size(11.5));
|
||||
let r = ui.add(egui::TextEdit::singleline(value).id(id).desired_width(72.0));
|
||||
if focus {
|
||||
r.request_focus();
|
||||
let n = value.chars().count();
|
||||
let mut st = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default();
|
||||
st.cursor.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
|
||||
st.store(ui.ctx(), id);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32) {
|
||||
let v = b - a;
|
||||
let l = v.length();
|
||||
if l < 1.0 {
|
||||
return;
|
||||
}
|
||||
let u = v / l;
|
||||
let n = vec2(-u.y, u.x);
|
||||
painter.line_segment([a, b], Stroke::new(2.0, col));
|
||||
let head = 11.0f32.min(l * 0.6);
|
||||
painter.add(egui::Shape::convex_polygon(vec![b, b - u * head + n * head * 0.45, b - u * head - n * head * 0.45], col, Stroke::NONE));
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use egui::{Color32, RichText, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::Sel;
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_engine::doc::expr::Kind as ValueKind;
|
||||
use solvecraft_engine::doc::{AxisRef, Direction, FeatureKind, HoleKind, Operation, PlaneRef, ProfileSel};
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
|
||||
@@ -95,6 +96,8 @@ pub struct Dialog {
|
||||
pub error: Option<String>,
|
||||
/// Editing an existing feature (its id, and the timeline marker to restore afterwards).
|
||||
pub editing: Option<(u64, Option<usize>)>,
|
||||
/// Give the on-canvas value box the keyboard (and select its text) on the next frame.
|
||||
pub focus: bool,
|
||||
/// Parameters the dialog doesn't show but the command needs (kept when editing).
|
||||
pub extra: serde_json::Map<String, Value>,
|
||||
}
|
||||
@@ -115,7 +118,7 @@ fn fits(a: Accept, s: &Sel) -> bool {
|
||||
|
||||
impl Dialog {
|
||||
fn new(kind: Kind, inputs: Vec<SelInput>) -> Dialog {
|
||||
Dialog { kind, inputs, active: 0, error: None, editing: None, extra: serde_json::Map::new() }
|
||||
Dialog { kind, inputs, active: 0, error: None, editing: None, focus: true, extra: serde_json::Map::new() }
|
||||
}
|
||||
|
||||
pub fn for_command(app: &SolveApp, id: &str) -> Option<Dialog> {
|
||||
@@ -125,7 +128,7 @@ impl Dialog {
|
||||
"SketchCreate" => Dialog::new(Kind::Sketch, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]),
|
||||
"Extrude" => Dialog::new(
|
||||
Kind::Extrude { distance: "10 mm".into(), direction: 0, operation: usize::from(has_bodies) },
|
||||
vec![SelInput::new("Profiles", PROFILES, true)],
|
||||
vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)],
|
||||
),
|
||||
"Revolve" => Dialog::new(
|
||||
Kind::Revolve { angle: "360 deg".into(), operation: 0 },
|
||||
@@ -226,6 +229,19 @@ impl Dialog {
|
||||
)
|
||||
}
|
||||
|
||||
/// The main value (shown on the canvas too): label, kind and the expression.
|
||||
pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> {
|
||||
Some(match &mut self.kind {
|
||||
Kind::Extrude { distance, .. } => ("Distance", ValueKind::Length, distance),
|
||||
Kind::Revolve { angle, .. } => ("Angle", ValueKind::Angle, angle),
|
||||
Kind::Fillet { radius, chamfer, .. } => (if *chamfer { "Distance" } else { "Radius" }, ValueKind::Length, radius),
|
||||
Kind::Shell { thickness } => ("Thickness", ValueKind::Length, thickness),
|
||||
Kind::Draft { angle } => ("Angle", ValueKind::Angle, angle),
|
||||
Kind::Hole { diameter, .. } => ("Diameter", ValueKind::Length, diameter),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn wants_picks(&self) -> bool {
|
||||
!self.inputs.is_empty()
|
||||
}
|
||||
@@ -271,10 +287,11 @@ impl Dialog {
|
||||
if let Sel::Profile { sketch, .. } = &sel
|
||||
&& let Some(inp) = self.inputs.get_mut(self.active)
|
||||
{
|
||||
inp.items.retain(|x| matches!(x, Sel::Profile { sketch: s2, .. } if s2 == sketch));
|
||||
inp.items.retain(|x| !matches!(x, Sel::Profile { sketch: s2, .. } if s2 != sketch));
|
||||
}
|
||||
let Some(inp) = self.inputs.get_mut(self.active) else { return };
|
||||
inp.toggle(picked);
|
||||
self.focus = true;
|
||||
if !inp.multi && !inp.items.is_empty() && self.active + 1 < self.inputs.len() {
|
||||
self.advance();
|
||||
}
|
||||
@@ -343,6 +360,12 @@ fn plane_value(s: &Session, sel: Option<&Sel>) -> Option<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A value field; true when Enter was pressed in it.
|
||||
fn field(ui: &mut egui::Ui, s: &mut String) -> bool {
|
||||
let r = ui.text_edit_singleline(s);
|
||||
r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))
|
||||
}
|
||||
|
||||
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(FIELD_W).show_ui(ui, |ui| {
|
||||
for (i, l) in labels.iter().enumerate() {
|
||||
@@ -408,7 +431,11 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
|
||||
} else {
|
||||
RichText::new(format!("{} selected", inp.items.len())).color(t.text)
|
||||
};
|
||||
let b = egui::Button::new(text).fill(if active { t.accent_soft } else { Color32::TRANSPARENT }).min_size(vec2(FIELD_W, 22.0));
|
||||
// Not in the Tab order: Tab moves between values.
|
||||
let b = egui::Button::new(text)
|
||||
.fill(if active { t.accent_soft } else { Color32::TRANSPARENT })
|
||||
.min_size(vec2(FIELD_W, 22.0))
|
||||
.sense(egui::Sense::CLICK);
|
||||
if ui.add(b).on_hover_text("Click to make this the input that viewport picks go to").clicked() {
|
||||
activate = Some(i);
|
||||
}
|
||||
@@ -445,6 +472,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let mut keep = true;
|
||||
let mut ok = false;
|
||||
let mut cancel = false;
|
||||
let mut enter = false;
|
||||
let wide = matches!(d.kind, Kind::Params { .. });
|
||||
let heading = if d.editing.is_some() { format!("EDIT {}", title(&d.kind)) } else { title(&d.kind).to_string() };
|
||||
let frame = egui::Frame::window(&ctx.global_style()).corner_radius(egui::CornerRadius { nw: 6, sw: 6, ne: 0, se: 0 }).shadow(egui::Shadow {
|
||||
@@ -478,7 +506,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
combo(ui, "ex_dir", &DIR_LABELS, direction);
|
||||
ui.end_row();
|
||||
ui.label("Distance");
|
||||
ui.text_edit_singleline(distance);
|
||||
enter |= field(ui, distance);
|
||||
ui.end_row();
|
||||
ui.label("Operation");
|
||||
combo(ui, "ex_op", &OP_LABELS, operation);
|
||||
@@ -486,7 +514,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
Kind::Revolve { angle, operation } => {
|
||||
ui.label("Angle");
|
||||
ui.text_edit_singleline(angle);
|
||||
enter |= field(ui, angle);
|
||||
ui.end_row();
|
||||
ui.label("Operation");
|
||||
combo(ui, "rv_op", &OP_LABELS, operation);
|
||||
@@ -494,7 +522,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
Kind::Fillet { radius, chamfer, chain } => {
|
||||
ui.label(if *chamfer { "Distance" } else { "Radius" });
|
||||
ui.text_edit_singleline(radius);
|
||||
enter |= field(ui, radius);
|
||||
ui.end_row();
|
||||
ui.label("Tangent chain");
|
||||
ui.checkbox(chain, "");
|
||||
@@ -502,12 +530,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
Kind::Shell { thickness } => {
|
||||
ui.label("Inside thickness");
|
||||
ui.text_edit_singleline(thickness);
|
||||
enter |= field(ui, thickness);
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Draft { angle } => {
|
||||
ui.label("Angle");
|
||||
ui.text_edit_singleline(angle);
|
||||
enter |= field(ui, angle);
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Mirror => {}
|
||||
@@ -516,32 +544,33 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
combo(ui, "hole_kind", &HOLE_LABELS, kind);
|
||||
ui.end_row();
|
||||
ui.label("Diameter");
|
||||
ui.text_edit_singleline(diameter);
|
||||
enter |= field(ui, diameter);
|
||||
ui.end_row();
|
||||
ui.label("Depth");
|
||||
ui.add(egui::TextEdit::singleline(depth).hint_text("through all"));
|
||||
let r = ui.add(egui::TextEdit::singleline(depth).hint_text("through all"));
|
||||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.end_row();
|
||||
if *kind == 1 {
|
||||
ui.label("Counterbore Ø");
|
||||
ui.text_edit_singleline(cb_diameter);
|
||||
enter |= field(ui, cb_diameter);
|
||||
ui.end_row();
|
||||
ui.label("Counterbore depth");
|
||||
ui.text_edit_singleline(cb_depth);
|
||||
enter |= field(ui, cb_depth);
|
||||
ui.end_row();
|
||||
}
|
||||
if *kind == 2 {
|
||||
ui.label("Countersink Ø");
|
||||
ui.text_edit_singleline(cs_diameter);
|
||||
enter |= field(ui, cs_diameter);
|
||||
ui.end_row();
|
||||
ui.label("Countersink angle");
|
||||
ui.text_edit_singleline(cs_angle);
|
||||
enter |= field(ui, cs_angle);
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
Kind::Primitive { fields, operation, .. } => {
|
||||
for (k, v) in fields.iter_mut() {
|
||||
ui.label(*k);
|
||||
ui.text_edit_singleline(v);
|
||||
enter |= field(ui, v);
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label("Operation");
|
||||
@@ -617,6 +646,16 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
});
|
||||
// Keyboard: Enter applies (from a value field, or with nothing focused), Esc cancels.
|
||||
let (enter_free, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
|
||||
let nothing_focused = ctx.memory(|m| m.focused().is_none());
|
||||
let canvas_enter = enter_free && ctx.memory(|m| m.had_focus_last_frame(egui::Id::new("sc_canvas_value")));
|
||||
if d.previews() && (enter || canvas_enter || (enter_free && nothing_focused)) {
|
||||
ok = true;
|
||||
}
|
||||
if esc {
|
||||
cancel = true;
|
||||
}
|
||||
// A plane picked for a new sketch starts it right away.
|
||||
if matches!(d.kind, Kind::Sketch)
|
||||
&& let Some(sel) = d.inputs.first().and_then(|i| i.items.first()).cloned()
|
||||
@@ -768,12 +807,28 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
};
|
||||
let (cmd, params): (&str, Value) = match &d.kind {
|
||||
Kind::Extrude { distance, direction, operation } => {
|
||||
need(0, "profiles")?;
|
||||
need(0, "profiles or a planar face")?;
|
||||
let common = json!({"distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||||
let with = |extra: Value| -> Value {
|
||||
let mut p = common.clone();
|
||||
if let (Value::Object(m), Value::Object(e)) = (&mut p, extra) {
|
||||
m.extend(e);
|
||||
for (k, v) in &d.extra {
|
||||
m.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
}
|
||||
p
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let (sketch, idx) = profiles();
|
||||
(
|
||||
"Extrude",
|
||||
json!({"sketch": sketch, "profiles": idx, "distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")}),
|
||||
)
|
||||
if !sketch.is_null() {
|
||||
out.push(("Extrude".to_string(), with(json!({"sketch": sketch, "profiles": idx}))));
|
||||
}
|
||||
// Each planar body face extrudes on its own.
|
||||
for p in face_points(0) {
|
||||
out.push(("Extrude".to_string(), with(json!({ "face": p }))));
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
Kind::Revolve { angle, operation } => {
|
||||
need(0, "profiles")?;
|
||||
|
||||
@@ -29,6 +29,8 @@ pub struct GpuScene {
|
||||
/// Translucent triangles pushed slightly back in depth, so they only show where nothing
|
||||
/// coincides with them (removed material in a preview).
|
||||
pub ghost: Vec<u8>,
|
||||
/// Translucent triangles drawn through everything (a cut's tool body).
|
||||
pub xray: Vec<u8>,
|
||||
}
|
||||
|
||||
impl GpuScene {
|
||||
@@ -50,6 +52,12 @@ impl GpuScene {
|
||||
}
|
||||
self.ghost.extend_from_slice(&c);
|
||||
}
|
||||
pub fn xray_tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) {
|
||||
for v in p.iter().chain(&n) {
|
||||
self.xray.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
self.xray.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) {
|
||||
@@ -93,6 +101,7 @@ struct Batches {
|
||||
lines: Option<Batch>,
|
||||
overlays: Option<Batch>,
|
||||
ghost: Option<Batch>,
|
||||
xray: Option<Batch>,
|
||||
}
|
||||
|
||||
impl Batches {
|
||||
@@ -106,6 +115,7 @@ impl Batches {
|
||||
self.lines = upload(device, "sc_lines", &sc.lines, LINE_SIZE);
|
||||
self.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE);
|
||||
self.ghost = upload(device, "sc_ghost", &sc.ghost, TRI_SIZE);
|
||||
self.xray = upload(device, "sc_xray", &sc.xray, TRI_SIZE);
|
||||
self.key = Some(key);
|
||||
}
|
||||
}
|
||||
@@ -117,6 +127,7 @@ struct Resources {
|
||||
tri_hl: wgpu::RenderPipeline,
|
||||
trans: wgpu::RenderPipeline,
|
||||
ghost: wgpu::RenderPipeline,
|
||||
xray: wgpu::RenderPipeline,
|
||||
line: wgpu::RenderPipeline,
|
||||
overlay: wgpu::RenderPipeline,
|
||||
uniform: wgpu::Buffer,
|
||||
@@ -309,6 +320,7 @@ impl Resources {
|
||||
tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
|
||||
trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
|
||||
ghost: pipeline("sc_ghost", "vs_ghost", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
|
||||
xray: pipeline("sc_xray", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Always, false), alpha),
|
||||
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,
|
||||
@@ -392,6 +404,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
tris(pass, &res.ghost, &pv.ghost);
|
||||
tris(pass, &res.trans, &m.trans);
|
||||
tris(pass, &res.trans, &h.trans);
|
||||
tris(pass, &res.xray, &pv.xray);
|
||||
lines(pass, &res.overlay, &m.overlays);
|
||||
lines(pass, &res.overlay, &h.overlays);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod browser;
|
||||
pub mod canvas;
|
||||
pub mod control;
|
||||
pub mod dialogs;
|
||||
pub mod gpu;
|
||||
|
||||
@@ -161,36 +161,75 @@ fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, V
|
||||
}
|
||||
|
||||
/// Evaluate the commands on the scratch session and build the preview scene.
|
||||
/// When the feature itself fails (a boolean the kernel can't do yet), the tool body alone is
|
||||
/// shown with the error, so the shape can still be judged.
|
||||
/// Features that add or remove a tool body (extrude, revolve, primitives with join, cut or
|
||||
/// intersect) show the tool itself: tinted for join, see-through red for cut. Other features
|
||||
/// show their result in place of the bodies they change. When the feature fails (a boolean the
|
||||
/// kernel can't do yet), the tool body is still shown, with the error.
|
||||
pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job {
|
||||
let (p, error) = match s.preview(cmds) {
|
||||
Ok(p) => (p, None),
|
||||
Err(e) => match as_new_body(cmds).and_then(|alt| s.preview(&alt).ok()) {
|
||||
Some(p) => (p, Some(e.to_string())),
|
||||
None => return Err(e.to_string()),
|
||||
},
|
||||
};
|
||||
let mut b = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build(&p.before, &p.after, colors)))
|
||||
.map_err(|_| "the preview failed".to_string())?;
|
||||
b.error = error;
|
||||
Ok(b)
|
||||
let real = s.preview(cmds);
|
||||
let guard = |f: &dyn Fn() -> Built| std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|_| "the preview failed".to_string());
|
||||
match as_new_body(cmds) {
|
||||
Some((alt, op)) => {
|
||||
let tool = match s.preview(&alt) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return Err(real.err().map_or_else(|| e.to_string(), |r| r.to_string())),
|
||||
};
|
||||
let mut b = guard(&|| build_tool(&tool.before, &tool.after, colors, &op))?;
|
||||
b.error = real.err().map(|e| e.to_string());
|
||||
Ok(b)
|
||||
}
|
||||
None => {
|
||||
let p = real.map_err(|e| e.to_string())?;
|
||||
guard(&|| build(&p.before, &p.after, colors))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same commands making a new body instead of joining, cutting or intersecting.
|
||||
fn as_new_body(cmds: &[(String, Value)]) -> Option<Vec<(String, Value)>> {
|
||||
/// The tool bodies a join/cut/intersect adds: opaque and tinted for join, see-through for cut
|
||||
/// (red) and intersect.
|
||||
fn build_tool(before: &ModelState, after: &ModelState, c: Colors, op: &str) -> Built {
|
||||
let mut sc = GpuScene::default();
|
||||
for a in after.bodies.iter().filter(|a| before.body(&a.name).is_none()) {
|
||||
let m = a.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)) {
|
||||
match op {
|
||||
"cut" => sc.xray_tri(p.to_f32(), n.to_f32(), c.removed),
|
||||
"intersect" => sc.xray_tri(p.to_f32(), n.to_f32(), [c.added[0], c.added[1], c.added[2], c.removed[3]]),
|
||||
_ => sc.tri(p.to_f32(), n.to_f32(), c.added),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let edge = if op == "cut" { [c.removed[0], c.removed[1], c.removed[2], 255] } else { c.edge };
|
||||
for (ei, e) in m.edges.iter().enumerate() {
|
||||
if !m.seams.get(ei).copied().unwrap_or(false) {
|
||||
for w in e.windows(2) {
|
||||
sc.line(w[0].to_f32(), w[1].to_f32(), edge, 1.3, op != "join");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Built { scene: sc, replaced: Vec::new(), error: None }
|
||||
}
|
||||
|
||||
/// The same commands making a new body instead of joining, cutting or intersecting, and the
|
||||
/// operation they had.
|
||||
fn as_new_body(cmds: &[(String, Value)]) -> Option<(Vec<(String, Value)>, String)> {
|
||||
let mut out = cmds.to_vec();
|
||||
let mut changed = false;
|
||||
let mut changed: Option<String> = None;
|
||||
for (_, p) in &mut out {
|
||||
let p = if p.get("command").is_some() { p.get_mut("params")? } else { p };
|
||||
if let Some(op) = p.get_mut("operation")
|
||||
&& op.as_str().is_some_and(|o| o != "new")
|
||||
&& let Some(o) = op.as_str().filter(|o| *o != "new").map(str::to_string)
|
||||
{
|
||||
*op = Value::from("new");
|
||||
changed = true;
|
||||
changed = Some(o);
|
||||
}
|
||||
}
|
||||
changed.then_some(out)
|
||||
changed.map(|op| (out, op))
|
||||
}
|
||||
|
||||
fn same(a: &Mesh, b: &Mesh) -> bool {
|
||||
@@ -333,6 +372,19 @@ mod tests {
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
|
||||
/// A cut shows its tool see-through and replaces nothing.
|
||||
#[test]
|
||||
fn cut_preview_scene() {
|
||||
let mut s = plate();
|
||||
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute("CircleCenterRadius", &json!({"center": [20, 15], "radius": 5})).unwrap();
|
||||
s.execute("SketchStop", &json!({})).unwrap();
|
||||
let b = compute(&s, &[("Extrude".into(), json!({"distance": 20, "operation": "cut"}))], colors()).unwrap();
|
||||
assert!(b.replaced.is_empty());
|
||||
assert!(b.scene.tris.is_empty() && !b.scene.xray.is_empty());
|
||||
assert!(b.error.is_none());
|
||||
}
|
||||
|
||||
/// A new body is all new surface and replaces nothing; a bad value is an error.
|
||||
#[test]
|
||||
fn extrude_preview_scene() {
|
||||
|
||||
@@ -54,6 +54,8 @@ pub struct Tokens {
|
||||
// ---- bodies and live previews ----
|
||||
pub body: Color32,
|
||||
pub body_edge: Color32,
|
||||
/// Command manipulators (direction arrows, handles).
|
||||
pub manipulator: Color32,
|
||||
/// New surface in a feature preview.
|
||||
pub preview_add: Color32,
|
||||
/// Removed material in a feature preview (translucent).
|
||||
@@ -104,6 +106,7 @@ impl Tokens {
|
||||
construction_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 70),
|
||||
body: Color32::from_rgb(176, 186, 198),
|
||||
body_edge: Color32::from_rgb(40, 44, 52),
|
||||
manipulator: Color32::from_rgb(40, 110, 220),
|
||||
preview_add: Color32::from_rgb(112, 160, 226),
|
||||
preview_cut: Color32::from_rgba_unmultiplied_const(232, 84, 44, 110),
|
||||
box_window: Color32::from_rgb(38, 120, 218),
|
||||
|
||||
@@ -901,6 +901,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if let Some(tl) = app.tool.as_ref() {
|
||||
crate::tools::preview(app, tl, &painter, &proj);
|
||||
}
|
||||
crate::canvas::show(app, ui, &painter, &proj);
|
||||
view_cube(app, ui, rect);
|
||||
nav_bar(app, ui, rect);
|
||||
// Status chip.
|
||||
|
||||
Reference in new issue
Block a user