Extrude: Two sides direction with a second draggable arrow, taper angle field; ground grid drawn under the model (bodies below the grid plane are no longer hidden by it); preview extent included in the view depth range
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
a13f4664dc
commit
a1accbf46e
5 files changed
+131
-48
No files matched your search
@@ -117,6 +117,12 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
let focus = std::mem::take(&mut d.focus);
|
||||
let half_height = app.cam.half_height();
|
||||
let axis = revolve_axis(app, d);
|
||||
// Two sides: the second distance gets its own arrow the other way.
|
||||
if let (Kind::Extrude { direction: crate::dialogs::TWO_SIDES, distance2, .. }, Some(n)) = (&mut d.kind, dir)
|
||||
&& let Some(l2) = app.session.doc.eval(distance2, ValueKind::Length).ok().filter(|v| v.is_finite())
|
||||
{
|
||||
drag_arrow(ui, painter, proj, base, bs, -n, l2, false, egui::Id::new("sc_manipulator2"), half_height, distance2);
|
||||
}
|
||||
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);
|
||||
@@ -127,36 +133,11 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
}
|
||||
if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) {
|
||||
let n = n * sign;
|
||||
// A short arrow still shows (and can be grabbed) when the value is near zero.
|
||||
let min = 40.0 * 2.0 * half_height / f64::from(proj.rect.height().max(1.0));
|
||||
let shown = if l.abs() < min { min * if l < 0.0 { -1.0 } else { 1.0 } } else { l };
|
||||
let tip = base + n * shown;
|
||||
if let Some(ts) = proj.to_screen(tip) {
|
||||
let handle = egui::Rect::from_center_size(ts, vec2(20.0, 20.0));
|
||||
let r = ui.interact(handle, egui::Id::new("sc_manipulator"), egui::Sense::drag());
|
||||
let hot = r.hovered() || r.dragged();
|
||||
let col = if hot { t.accent } else { t.manipulator };
|
||||
arrow(painter, bs, ts, col);
|
||||
if symmetric && let Some(bt) = proj.to_screen(base - n * shown) {
|
||||
arrow(painter, bs, bt, col);
|
||||
}
|
||||
painter.circle(bs, 3.5, col, Stroke::new(1.0, Color32::WHITE));
|
||||
if hot {
|
||||
painter.circle_stroke(ts, 8.0, Stroke::new(1.5, col));
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
if r.dragged()
|
||||
&& let Some(p) = r.interact_pointer_pos()
|
||||
&& let Some(v) = along(proj, p, base, n)
|
||||
{
|
||||
let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command);
|
||||
let step = snap_step(half_height);
|
||||
let mut v = if free { v } else { (v / step).round() * step };
|
||||
if !signed {
|
||||
v = v.max(if free { 1e-3 } else { step });
|
||||
}
|
||||
*value = format_len(v);
|
||||
if let Some(ts) = drag_arrow(ui, painter, proj, base, bs, n, l, signed, egui::Id::new("sc_manipulator"), half_height, value) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -178,6 +159,49 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
});
|
||||
}
|
||||
|
||||
/// A draggable arrow from `base` along `n` showing `l` (at least a short stub); dragging writes
|
||||
/// the snapped length into `value`. Returns the tip's screen position.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drag_arrow(
|
||||
ui: &mut egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
proj: &Proj,
|
||||
base: Vec3,
|
||||
bs: Pos2,
|
||||
n: Vec3,
|
||||
l: f64,
|
||||
signed: bool,
|
||||
id: egui::Id,
|
||||
half_height: f64,
|
||||
value: &mut String,
|
||||
) -> Option<Pos2> {
|
||||
let t = Tokens::get();
|
||||
let min = 40.0 * 2.0 * half_height / f64::from(proj.rect.height().max(1.0));
|
||||
let shown = if l.abs() < min { min * if l < 0.0 { -1.0 } else { 1.0 } } else { l };
|
||||
let ts = proj.to_screen(base + n * shown)?;
|
||||
let r = ui.interact(egui::Rect::from_center_size(ts, vec2(20.0, 20.0)), id, egui::Sense::drag());
|
||||
let hot = r.hovered() || r.dragged();
|
||||
let col = if hot { t.accent } else { t.manipulator };
|
||||
arrow(painter, bs, ts, col);
|
||||
if hot {
|
||||
painter.circle_stroke(ts, 8.0, Stroke::new(1.5, col));
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
if r.dragged()
|
||||
&& let Some(p) = r.interact_pointer_pos()
|
||||
&& let Some(v) = along(proj, p, base, n)
|
||||
{
|
||||
let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command);
|
||||
let step = snap_step(half_height);
|
||||
let mut v = if free { v } else { (v / step).round() * step };
|
||||
if !signed {
|
||||
v = v.max(if free { 1e-3 } else { step });
|
||||
}
|
||||
*value = format_len(v);
|
||||
}
|
||||
Some(ts)
|
||||
}
|
||||
|
||||
/// The revolve axis (a point on it and its unit direction).
|
||||
fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> {
|
||||
if !matches!(d.kind, Kind::Revolve { .. }) {
|
||||
|
||||
@@ -19,8 +19,10 @@ 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"];
|
||||
const DIRS: [&str; 4] = ["positive", "negative", "symmetric", "positive"];
|
||||
const DIR_LABELS: [&str; 4] = ["One side", "Flip", "Symmetric", "Two sides"];
|
||||
/// The "Two sides" direction (a second distance the other way).
|
||||
pub const TWO_SIDES: usize = 3;
|
||||
const HOLE_TYPES: [&str; 3] = ["simple", "counterbore", "countersink"];
|
||||
const HOLE_LABELS: [&str; 3] = ["Simple", "Counterbore", "Countersink"];
|
||||
|
||||
@@ -35,6 +37,10 @@ pub enum Kind {
|
||||
/// The operation follows the geometry (into a body: cut, out of one: join, else new)
|
||||
/// until the user picks one.
|
||||
auto_op: bool,
|
||||
/// The other side's distance (two sides).
|
||||
distance2: String,
|
||||
/// Taper angle (empty: none).
|
||||
taper: String,
|
||||
},
|
||||
Revolve {
|
||||
angle: String,
|
||||
@@ -157,7 +163,14 @@ impl Dialog {
|
||||
let mut d = match id {
|
||||
"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), auto_op: true },
|
||||
Kind::Extrude {
|
||||
distance: "10 mm".into(),
|
||||
direction: 0,
|
||||
operation: usize::from(has_bodies),
|
||||
auto_op: true,
|
||||
distance2: "10 mm".into(),
|
||||
taper: String::new(),
|
||||
},
|
||||
vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)],
|
||||
),
|
||||
"Revolve" => Dialog::new(
|
||||
@@ -594,13 +607,23 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
input_rows(&mut d, ui);
|
||||
match &mut d.kind {
|
||||
Kind::Sketch => {}
|
||||
Kind::Extrude { distance, direction, operation, auto_op } => {
|
||||
Kind::Extrude { distance, direction, operation, auto_op, distance2, taper } => {
|
||||
ui.label("Direction");
|
||||
combo(ui, "ex_dir", &DIR_LABELS, direction);
|
||||
ui.end_row();
|
||||
ui.label("Distance");
|
||||
enter |= field(ui, distance);
|
||||
ui.end_row();
|
||||
if *direction == TWO_SIDES {
|
||||
ui.label("Distance 2");
|
||||
enter |= field(ui, distance2);
|
||||
ui.end_row();
|
||||
} else if *direction < 2 {
|
||||
ui.label("Taper angle");
|
||||
let r = ui.add(egui::TextEdit::singleline(taper).hint_text("0 deg"));
|
||||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label("Operation");
|
||||
let before = *operation;
|
||||
combo(ui, "ex_op", &OP_LABELS, operation);
|
||||
@@ -938,9 +961,14 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
sels(d, i).iter().filter_map(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }).collect()
|
||||
};
|
||||
let (cmd, params): (&str, Value) = match &d.kind {
|
||||
Kind::Extrude { distance, direction, operation, .. } => {
|
||||
Kind::Extrude { distance, direction, operation, distance2, taper, .. } => {
|
||||
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 mut common = json!({"distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||||
if *direction == TWO_SIDES {
|
||||
common["distance2"] = json!(distance2);
|
||||
} else if *direction < 2 && !taper.trim().is_empty() {
|
||||
common["taper"] = json!(taper);
|
||||
}
|
||||
let with = |extra: Value| -> Value {
|
||||
let mut p = common.clone();
|
||||
if let (Value::Object(m), Value::Object(e)) = (&mut p, extra) {
|
||||
@@ -1284,22 +1312,23 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
let mut d = start("Extrude")?;
|
||||
d.kind = Kind::Extrude {
|
||||
distance: extent.distance.clone(),
|
||||
direction: match extent.direction {
|
||||
Direction::Positive => 0,
|
||||
Direction::Negative => 1,
|
||||
Direction::Symmetric => 2,
|
||||
direction: match (extent.direction, &extent.distance2) {
|
||||
(_, Some(_)) => TWO_SIDES,
|
||||
(Direction::Positive, None) => 0,
|
||||
(Direction::Negative, None) => 1,
|
||||
(Direction::Symmetric, None) => 2,
|
||||
},
|
||||
operation: op_index(operation),
|
||||
auto_op: false,
|
||||
distance2: extent.distance2.clone().unwrap_or_else(|| "10 mm".into()),
|
||||
taper: extent.taper.clone().unwrap_or_default(),
|
||||
};
|
||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||
if let Some(inp) = d.inputs.first_mut() {
|
||||
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
|
||||
}
|
||||
for (k, v) in [
|
||||
("distance2", extent.distance2.as_ref().map(|x| json!(x))),
|
||||
("start_offset", extent.start_offset.as_ref().map(|x| json!(x))),
|
||||
("taper", extent.taper.as_ref().map(|x| json!(x))),
|
||||
("through_all", extent.through_all.then_some(json!(true))),
|
||||
("targets", (!targets.is_empty()).then(|| json!(targets))),
|
||||
] {
|
||||
|
||||
@@ -31,6 +31,9 @@ pub struct GpuScene {
|
||||
pub ghost: Vec<u8>,
|
||||
/// Translucent triangles drawn through everything (a cut's tool body).
|
||||
pub xray: Vec<u8>,
|
||||
/// Lines drawn first, under everything (the ground grid: the model hides it, it never
|
||||
/// hides the model).
|
||||
pub under: Vec<u8>,
|
||||
}
|
||||
|
||||
impl GpuScene {
|
||||
@@ -58,6 +61,14 @@ impl GpuScene {
|
||||
}
|
||||
self.xray.extend_from_slice(&c);
|
||||
}
|
||||
/// A line under everything (see [`GpuScene::under`]).
|
||||
pub fn under_line(&mut self, a: [f32; 3], b: [f32; 3], c: [u8; 4], width: f32) {
|
||||
for v in a.iter().chain(&b) {
|
||||
self.under.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
self.under.extend_from_slice(&c);
|
||||
self.under.extend_from_slice(&width.to_le_bytes());
|
||||
}
|
||||
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) {
|
||||
@@ -102,6 +113,7 @@ struct Batches {
|
||||
overlays: Option<Batch>,
|
||||
ghost: Option<Batch>,
|
||||
xray: Option<Batch>,
|
||||
under: Option<Batch>,
|
||||
}
|
||||
|
||||
impl Batches {
|
||||
@@ -116,6 +128,7 @@ impl Batches {
|
||||
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.under = upload(device, "sc_under", &sc.under, LINE_SIZE);
|
||||
self.key = Some(key);
|
||||
}
|
||||
}
|
||||
@@ -395,6 +408,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
}
|
||||
};
|
||||
let (m, h, pv) = (&res.model, &res.highlight, &res.preview);
|
||||
lines(pass, &res.overlay, &m.under);
|
||||
tris(pass, &res.tri, &m.tris);
|
||||
tris(pass, &res.tri, &pv.tris);
|
||||
tris(pass, &res.tri_hl, &h.tris);
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
use serde_json::Value;
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_engine::doc::ModelState;
|
||||
use solvecraft_engine::geom::{Mesh, Vec3};
|
||||
use solvecraft_engine::geom::{Aabb3, Mesh, Vec3};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::gpu::{GpuScene, SceneSlot};
|
||||
@@ -39,6 +39,8 @@ pub struct Built {
|
||||
pub replaced: Vec<String>,
|
||||
/// Why the feature failed, when the scene only shows its tool body.
|
||||
pub error: Option<String>,
|
||||
/// Extent of what the preview shows (the view's depth range must cover it).
|
||||
pub bounds: Aabb3,
|
||||
}
|
||||
|
||||
type Job = Result<Built, String>;
|
||||
@@ -59,6 +61,8 @@ pub struct PreviewState {
|
||||
pub error: Option<String>,
|
||||
/// A preview is being computed.
|
||||
pub busy: bool,
|
||||
/// Extent of the preview scene.
|
||||
pub bounds: Aabb3,
|
||||
/// Time the last preview took (ms), for `ui.inspect`.
|
||||
pub ms: f64,
|
||||
started: f64,
|
||||
@@ -72,6 +76,7 @@ impl PreviewState {
|
||||
self.busy = false;
|
||||
if self.active || !self.replaced.is_empty() {
|
||||
self.active = false;
|
||||
self.bounds = Aabb3::EMPTY;
|
||||
self.replaced.clear();
|
||||
self.set_scene(GpuScene::default());
|
||||
}
|
||||
@@ -90,6 +95,7 @@ impl PreviewState {
|
||||
match job {
|
||||
Ok(b) => {
|
||||
self.replaced = b.replaced;
|
||||
self.bounds = b.bounds;
|
||||
self.active = true;
|
||||
self.error = b.error;
|
||||
self.set_scene(b.scene);
|
||||
@@ -176,15 +182,22 @@ pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job {
|
||||
};
|
||||
let mut b = guard(&|| build_tool(&tool.before, &tool.after, colors, &op))?;
|
||||
b.error = real.err().map(|e| e.to_string());
|
||||
b.bounds = state_bounds(&tool.after);
|
||||
Ok(b)
|
||||
}
|
||||
None => {
|
||||
let p = real.map_err(|e| e.to_string())?;
|
||||
guard(&|| build(&p.before, &p.after, colors))
|
||||
let mut b = guard(&|| build(&p.before, &p.after, colors))?;
|
||||
b.bounds = state_bounds(&p.after);
|
||||
Ok(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn state_bounds(st: &ModelState) -> Aabb3 {
|
||||
st.bodies.iter().fold(Aabb3::EMPTY, |b, x| b.union(&x.mesh().bounds()))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -212,7 +225,7 @@ fn build_tool(before: &ModelState, after: &ModelState, c: Colors, op: &str) -> B
|
||||
}
|
||||
}
|
||||
}
|
||||
Built { scene: sc, replaced: Vec::new(), error: None }
|
||||
Built { scene: sc, replaced: Vec::new(), error: None, bounds: Aabb3::EMPTY }
|
||||
}
|
||||
|
||||
/// The same commands making a new body instead of joining, cutting or intersecting, and the
|
||||
@@ -288,7 +301,7 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built {
|
||||
replaced.push(b.name.clone());
|
||||
}
|
||||
}
|
||||
Built { scene: sc, replaced, error: None }
|
||||
Built { scene: sc, replaced, error: None, bounds: Aabb3::EMPTY }
|
||||
}
|
||||
|
||||
/// Is face `f` of `m` new surface, i.e. not on the old mesh? Decided by a vote of points on it
|
||||
|
||||
@@ -117,7 +117,10 @@ fn rgba(c: Rgb) -> [u8; 4] {
|
||||
}
|
||||
|
||||
pub fn scene_radius(app: &SolveApp) -> f64 {
|
||||
let b = solvecraft_engine::view::bounds(&app.session);
|
||||
let mut b = solvecraft_engine::view::bounds(&app.session);
|
||||
if !app.preview.bounds.is_empty() {
|
||||
b = b.union(&app.preview.bounds);
|
||||
}
|
||||
let r = if b.is_empty() { 50.0 } else { b.diagonal() * 0.5 + b.center().dist(app.cam.target) };
|
||||
r.max(app.cam.half_height() * 2.0).max(10.0)
|
||||
}
|
||||
@@ -181,8 +184,8 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
let maj = (t / major).round() * major == t;
|
||||
let col = if maj { c4(tk.grid_major) } else { c4(tk.grid) };
|
||||
let w = if maj { 1.0 } else { 0.7 };
|
||||
sc.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);
|
||||
sc.under_line((c + Vec3::new(t, -ext, 0.0)).to_f32(), (c + Vec3::new(t, ext, 0.0)).to_f32(), col, w);
|
||||
sc.under_line((c + Vec3::new(-ext, t, 0.0)).to_f32(), (c + Vec3::new(ext, t, 0.0)).to_f32(), col, w);
|
||||
}
|
||||
}
|
||||
for b in &st.bodies {
|
||||
|
||||
Reference in new issue
Block a user