Merge remote-tracking branch 'origin/main' into fix/model-render-section
# Conflicts: # crates/ui-egui/src/viewport.rs
This commit is contained in:
commit
5a2174899f
186 files changed
+29730
-1578
No files matched your search
@@ -27,7 +27,7 @@ pub fn map(app: &SolveApp) -> Option<AccessMap> {
|
||||
return Some(m);
|
||||
}
|
||||
let st = app.session.world_state();
|
||||
let meshes: Vec<_> = st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name)).map(|b| b.mesh()).collect();
|
||||
let meshes: Vec<_> = st.bodies.iter().filter(|b| app.session.body_visible(&b.name)).map(|b| b.mesh()).collect();
|
||||
// Every new map gets a new version (the GPU uploads it once).
|
||||
let version = VERSION.with(|v| {
|
||||
v.set(v.get() + 1);
|
||||
|
||||
+224
-68
@@ -6,6 +6,10 @@
|
||||
//! viewport, a double-click on a sketch edits it, and a right-click opens the item's menu
|
||||
//! ([`crate::context_menu`]).
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "occurrence_ui_tests.rs"]
|
||||
mod occurrence_ui_tests;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use egui::{Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2};
|
||||
@@ -21,6 +25,7 @@ thread_local! {
|
||||
/// The rows drawn this frame: (label, row, fold arrow area, open). For automation
|
||||
/// (`ui.at {browser | chevron}`, `ui.browser`).
|
||||
static DRAWN: std::cell::RefCell<Vec<(String, Rect, Option<Rect>, Option<bool>)>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
static OCCURRENCES: std::cell::RefCell<Vec<(Vec<u64>, String, Rect, bool)>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// The rows on screen: (label, row rect, fold arrow area, open).
|
||||
@@ -28,6 +33,10 @@ pub fn drawn_rows() -> Vec<(String, Rect, Option<Rect>, Option<bool>)> {
|
||||
DRAWN.with(|d| d.borrow().clone())
|
||||
}
|
||||
|
||||
pub fn drawn_occurrences() -> Vec<(Vec<u64>, String, Rect, bool)> {
|
||||
OCCURRENCES.with(|d| d.borrow().clone())
|
||||
}
|
||||
|
||||
/// Browser state kept between frames.
|
||||
#[derive(Default)]
|
||||
pub struct TreeState {
|
||||
@@ -41,6 +50,7 @@ pub struct TreeState {
|
||||
pub occurrence_move: Option<OccMove>,
|
||||
/// The Redefine Sketch Plane panel (sketch id).
|
||||
pub redefine: Option<u64>,
|
||||
pub redefine_draft: Option<crate::sketch_attachment_ui::Draft>,
|
||||
/// Components picked in the tree (they have no viewport selection).
|
||||
pub picked_components: Vec<u64>,
|
||||
/// Canvases picked in the tree.
|
||||
@@ -59,6 +69,11 @@ pub struct TreeState {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OccMove {
|
||||
pub occurrence: u64,
|
||||
/// Exact assembly placement used for world-space movement and the pivot.
|
||||
pub path: Vec<u64>,
|
||||
/// Effective local placement at dialog opening, including its pre-existing pending move.
|
||||
pub baseline: Option<solvecraft_engine::doc::Mat>,
|
||||
pub had_pending: bool,
|
||||
/// Distances along X, Y, Z (mm) and turns about X, Y, Z (degrees) from where it was.
|
||||
pub translate: [f64; 3],
|
||||
pub angles: [f64; 3],
|
||||
@@ -70,7 +85,7 @@ pub struct OccMove {
|
||||
|
||||
impl OccMove {
|
||||
pub fn new(occurrence: u64) -> Self {
|
||||
OccMove { occurrence, translate: [0.0; 3], angles: [0.0; 3], pivot: None, drag0: None }
|
||||
OccMove { occurrence, path: Vec::new(), baseline: None, had_pending: false, translate: [0.0; 3], angles: [0.0; 3], pivot: None, drag0: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +113,7 @@ pub fn set_sketch_visible(app: &mut SolveApp, id: u64, on: bool) {
|
||||
|
||||
pub fn redefine_sketch(app: &mut SolveApp, id: u64) {
|
||||
app.tree.redefine = Some(id);
|
||||
app.tree.redefine_draft = Some(crate::sketch_attachment_ui::Draft::new(app, id));
|
||||
}
|
||||
|
||||
fn find_group(app: &SolveApp, id: u64) -> Option<&solvecraft_engine::doc::BrowserGroup> {
|
||||
@@ -188,7 +204,7 @@ pub fn selection_group(app: &SolveApp, target: &Target) -> Option<Value> {
|
||||
Target::Body { name } => {
|
||||
let st = app.session.world_state();
|
||||
let b = st.body(name)?;
|
||||
(app.session.doc.body_component(name, b.feature), "bodies")
|
||||
(b.component, "bodies")
|
||||
}
|
||||
Target::Sketch { id } => (app.session.doc.feature(*id)?.component, "sketches"),
|
||||
_ => return None,
|
||||
@@ -440,6 +456,13 @@ fn click_select(app: &mut SolveApp, ui: &egui::Ui, key: String, sel: Option<Sel>
|
||||
}
|
||||
|
||||
fn sel_of_key(k: &str) -> Option<Sel> {
|
||||
if let Some((_, id)) = k.rsplit_once("/s:") {
|
||||
return id.parse().ok().map(|id| Sel::Feature { id });
|
||||
}
|
||||
if let Some(p) = k.strip_prefix("o:") {
|
||||
let path: Option<Vec<u64>> = p.split('/').map(|id| id.parse().ok()).collect();
|
||||
return path.filter(|p| !p.is_empty()).map(|path| Sel::Occurrence { path });
|
||||
}
|
||||
if let Some(b) = k.strip_prefix("b:") {
|
||||
return Some(Sel::Body { name: b.to_string() });
|
||||
}
|
||||
@@ -475,6 +498,7 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
app.tree.order.clear();
|
||||
DRAWN.with(|d| d.borrow_mut().clear());
|
||||
OCCURRENCES.with(|d| d.borrow_mut().clear());
|
||||
let mut acts = Actions::default();
|
||||
egui::Panel::left("sc_browser")
|
||||
.exact_size(250.0)
|
||||
@@ -501,7 +525,8 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
occurrence_panel(app, ui);
|
||||
capture_prompt(app, ui.ctx());
|
||||
canvas_panel(app, ui.ctx());
|
||||
redefine_panel(app, ui.ctx());
|
||||
let repair_anchor = app.viewport.rect.map(|rect| rect.right_top() + vec2(-16.0, 16.0)).unwrap_or_else(|| ui.ctx().content_rect().right_top());
|
||||
crate::sketch_attachment_ui::panel(app, ui.ctx(), repair_anchor);
|
||||
}
|
||||
|
||||
fn component_name(app: &SolveApp, id: u64) -> String {
|
||||
@@ -598,9 +623,109 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize,
|
||||
if id == 0 {
|
||||
crate::dialogs_assembly::browser_joints(app, ui, d);
|
||||
}
|
||||
let children: Vec<u64> = app.session.doc.components.iter().filter(|c| c.parent == id).map(|c| c.id).collect();
|
||||
for c in children {
|
||||
component_rows(app, ui, c, d, acts);
|
||||
let children: Vec<u64> = app.session.doc.occurrences.iter().filter(|o| o.parent == id).map(|o| o.id).collect();
|
||||
for occurrence in children {
|
||||
occurrence_rows(app, ui, vec![occurrence], d, acts);
|
||||
}
|
||||
}
|
||||
|
||||
fn occurrence_key(path: &[u64]) -> String {
|
||||
format!("o:{}", path.iter().map(u64::to_string).collect::<Vec<_>>().join("/"))
|
||||
}
|
||||
|
||||
/// A placement, rather than a component definition: repeated child IDs remain distinct
|
||||
/// because their complete paths are the selection and row identity.
|
||||
fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec<u64>, depth: usize, acts: &mut Actions) {
|
||||
let Ok(placement) = app.session.occurrence_placement(&path) else { return };
|
||||
let key = occurrence_key(&path);
|
||||
let open = is_open(app, &key, true);
|
||||
let active = app.session.active_occurrence_path.as_deref() == Some(path.as_slice())
|
||||
|| (app.session.active_occurrence_path.is_none()
|
||||
&& app.session.active_component == placement.component
|
||||
&& app.session.doc.first_path(placement.component) == path);
|
||||
let visible = app.session.occurrence_visible(&path);
|
||||
let own_visible = !app.session.occurrence_hidden(&path);
|
||||
let selected = app.session.selection.contains(&Sel::Occurrence { path: path.clone() });
|
||||
let faded = app.session.active_occurrence_path.as_ref().is_some_and(|p| !path.starts_with(p) && !p.starts_with(&path));
|
||||
let badges: &[&str] = if placement.grounded { &["pin"] } else { &[] };
|
||||
app.tree.order.push(key.clone());
|
||||
let r = draw_row(
|
||||
ui,
|
||||
ui.id().with(&key),
|
||||
&Row {
|
||||
depth,
|
||||
fold: Some(open),
|
||||
eye: Some(own_visible),
|
||||
icon: "component",
|
||||
label: &placement.name,
|
||||
selected,
|
||||
dim: faded || !visible,
|
||||
radio: Some(active),
|
||||
badges,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if let Some(rect) = r.rect.filter(|_| r.resp.is_some()) {
|
||||
OCCURRENCES.with(|d| d.borrow_mut().push((path.clone(), placement.name.clone(), rect, open)));
|
||||
crate::scenario::publish_handle(&format!("row:occurrence:{}", key.trim_start_matches("o:")), pos2(rect.right() - 12.0, rect.center().y));
|
||||
let x = rect.left() + 2.0 + depth as f32 * INDENT;
|
||||
for (control, offset) in [("fold", 6.0), ("eye", 22.0), ("activate", 38.0)] {
|
||||
crate::scenario::publish_handle(&format!("{control}:occurrence:{}", key.trim_start_matches("o:")), pos2(x + offset, rect.center().y));
|
||||
}
|
||||
}
|
||||
if let Some(resp) = &r.resp {
|
||||
let definition = app.session.doc.components.iter().find(|c| c.id == placement.component).map(|c| c.name.as_str()).unwrap_or("");
|
||||
let location = path
|
||||
.iter()
|
||||
.filter_map(|id| app.session.doc.occurrences.iter().find(|o| o.id == *id))
|
||||
.map(|o| o.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
let inherited = if own_visible && !visible { "\nHidden by a parent placement. Show the parent to reveal this instance." } else { "" };
|
||||
resp.clone().on_hover_text(format!(
|
||||
"Component: {definition}\nPlacement: {}\nGeometry edits apply to every instance of this component.{inherited}",
|
||||
location
|
||||
));
|
||||
}
|
||||
if r.fold {
|
||||
toggle(app, &key, true);
|
||||
}
|
||||
if r.eye {
|
||||
let _ = app.run("browser.visibility", json!({"visible": app.session.occurrence_hidden(&path), "paths": [path]}));
|
||||
}
|
||||
if r.radio || r.double {
|
||||
let _ = app.run("component.activate", json!({"component": placement.component, "occurrence": placement.occurrence, "path": path}));
|
||||
}
|
||||
if r.clicked {
|
||||
click_select(app, ui, key.clone(), Some(Sel::Occurrence { path: path.clone() }));
|
||||
}
|
||||
if r.hovered {
|
||||
acts.hover_bodies = app.session.occurrence_bodies(&path, true).unwrap_or_default();
|
||||
}
|
||||
if let Some(p) = r.secondary {
|
||||
if !selected {
|
||||
let _ = app.run("select.set", json!({"items": [Sel::Occurrence { path: path.clone() }]}));
|
||||
}
|
||||
acts.menu = Some((p, Target::Occurrence { path: path.clone() }));
|
||||
}
|
||||
if let Some(resp) = &r.resp
|
||||
&& let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp != placement.component && d.folder != "construction", DropMark::Box)
|
||||
{
|
||||
acts.drop = Some(DropAction::Component { to: placement.component, folder: d.folder, keys: d.keys.clone() });
|
||||
}
|
||||
if !open {
|
||||
return;
|
||||
}
|
||||
for folder in ["bodies", "sketches", "construction"] {
|
||||
occurrence_folder_rows(app, ui, placement.component, &path, folder, depth + 1, acts);
|
||||
}
|
||||
let children: Vec<u64> = app.session.doc.occurrences.iter().filter(|o| o.parent == placement.component).map(|o| o.id).collect();
|
||||
if path.len() < 64 {
|
||||
for occurrence in children {
|
||||
let mut child = path.clone();
|
||||
child.push(occurrence);
|
||||
occurrence_rows(app, ui, child, depth + 1, acts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -998,13 +1123,13 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
|
||||
.state()
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| doc.body_component(&b.name, b.feature) == comp)
|
||||
.filter(|b| doc.model_body_component(b) == comp)
|
||||
.map(|b| Entry {
|
||||
key: b.name.clone(),
|
||||
row_key: format!("b:{}", b.name),
|
||||
label: b.name.clone(),
|
||||
icon: "body",
|
||||
visible: !app.session.visibility.hidden_bodies.contains(&b.name),
|
||||
visible: app.session.body_visible(&b.name),
|
||||
selected: sel.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name)),
|
||||
color: None,
|
||||
locked: app.ui.locked_bodies.contains(&b.name),
|
||||
@@ -1019,14 +1144,22 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
|
||||
.filter(|f| f.component == comp && matches!(f.kind, FeatureKind::Sketch { .. }))
|
||||
.map(|f| {
|
||||
let active = app.session.active_sketch == Some(f.id);
|
||||
let lost = app.session.model.result(f.id).is_some_and(|result| result.error.is_some())
|
||||
&& matches!(doc.sketch_face_attachment(f.id, &app.session.model), solvecraft_engine::doc::FaceAttachmentStatus::Lost { .. });
|
||||
Entry {
|
||||
key: f.id.to_string(),
|
||||
row_key: format!("s:{}", f.id),
|
||||
label: if active { format!("{} (editing)", f.name) } else { f.name.clone() },
|
||||
label: if lost {
|
||||
format!("{} (attachment lost)", f.name)
|
||||
} else if active {
|
||||
format!("{} (editing)", f.name)
|
||||
} else {
|
||||
f.name.clone()
|
||||
},
|
||||
icon: "sketch",
|
||||
visible: sketch_visible(app, f.id),
|
||||
selected: active || sel.iter().any(|x| matches!(x, Sel::Feature { id } if *id == f.id)),
|
||||
color: active.then_some(t.sketch_accent),
|
||||
color: if lost { Some(t.error) } else { active.then_some(t.sketch_accent) },
|
||||
locked: false,
|
||||
sel: Some(Sel::Feature { id: f.id }),
|
||||
target: Some(Target::Sketch { id: f.id }),
|
||||
@@ -1082,9 +1215,9 @@ fn set_visible(app: &mut SolveApp, folder: &str, items: &[&Entry], on: bool) {
|
||||
for e in items {
|
||||
match folder {
|
||||
"bodies" => {
|
||||
app.session.visibility.hidden_bodies.retain(|b| *b != e.key);
|
||||
app.session.visibility.hidden_bodies.retain(|b| !e.bodies.contains(b));
|
||||
if !on {
|
||||
app.session.visibility.hidden_bodies.push(e.key.clone());
|
||||
app.session.visibility.hidden_bodies.extend(e.bodies.clone());
|
||||
}
|
||||
}
|
||||
"sketches" => {
|
||||
@@ -1121,13 +1254,38 @@ fn folder_static(folder: &str) -> &'static str {
|
||||
/// A component's folder: its row, its groups and its items. Rows are drag sources; items,
|
||||
/// groups and the folder row are drop targets (reorder, into a group, out of groups).
|
||||
fn folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, folder: &str, depth: usize, acts: &mut Actions) {
|
||||
folder_rows_at(app, ui, comp, None, folder, depth, acts);
|
||||
}
|
||||
|
||||
fn occurrence_folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: &[u64], folder: &str, depth: usize, acts: &mut Actions) {
|
||||
folder_rows_at(app, ui, comp, Some(path), folder, depth, acts);
|
||||
}
|
||||
|
||||
fn folder_rows_at(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: Option<&[u64]>, folder: &str, depth: usize, acts: &mut Actions) {
|
||||
let t = Tokens::get();
|
||||
let all = entries(app, comp, folder);
|
||||
let mut all = entries(app, comp, folder);
|
||||
let prefix = path.map(occurrence_key).unwrap_or_else(|| format!("c{comp}"));
|
||||
if let Some(path) = path {
|
||||
let Ok(placement) = app.session.occurrence_placement(path) else { return };
|
||||
for e in &mut all {
|
||||
if folder == "bodies" {
|
||||
let Some(body) = placement.bodies.iter().find(|b| b.source == e.key) else { continue };
|
||||
e.sel = Some(Sel::Body { name: body.world.clone() });
|
||||
e.target = Some(Target::Body { name: body.world.clone() });
|
||||
e.bodies = vec![body.world.clone()];
|
||||
e.visible = app.session.body_visible(&body.world);
|
||||
e.selected = app.session.selection.contains(&Sel::Body { name: body.world.clone() });
|
||||
e.row_key = format!("b:{}", body.world);
|
||||
} else {
|
||||
e.row_key = format!("{prefix}/{}", e.row_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
if all.is_empty() {
|
||||
return;
|
||||
}
|
||||
let fstatic = folder_static(folder);
|
||||
let key = format!("c{comp}/{folder}");
|
||||
let key = format!("{prefix}/{folder}");
|
||||
if let Some(r) = app.tree.reveal.clone()
|
||||
&& folder == "bodies"
|
||||
&& all.iter().any(|e| e.key == r)
|
||||
@@ -1174,7 +1332,7 @@ fn folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, folder: &str, d
|
||||
let (groups, loose) = arrange(app, comp, folder, all);
|
||||
let loose_keys: Vec<String> = loose.iter().map(|e| e.key.clone()).collect();
|
||||
for (g, items) in &groups {
|
||||
let gkey = format!("g{}", g.id);
|
||||
let gkey = format!("{prefix}/g{}", g.id);
|
||||
let gopen = is_open(app, &gkey, true);
|
||||
let gvis = items.is_empty() || items.iter().any(|e| e.visible);
|
||||
let label = format!("{} ({})", g.name, items.len());
|
||||
@@ -1203,12 +1361,12 @@ fn folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, folder: &str, d
|
||||
}
|
||||
if gopen {
|
||||
for e in items {
|
||||
entry_row(app, ui, comp, fstatic, e, depth + 2, Some(g.id), &loose_keys, acts);
|
||||
entry_row(app, ui, comp, path, fstatic, e, depth + 2, Some(g.id), &loose_keys, acts);
|
||||
}
|
||||
}
|
||||
}
|
||||
for e in &loose {
|
||||
entry_row(app, ui, comp, fstatic, e, depth + 1, None, &loose_keys, acts);
|
||||
entry_row(app, ui, comp, path, fstatic, e, depth + 1, None, &loose_keys, acts);
|
||||
}
|
||||
let _ = t;
|
||||
}
|
||||
@@ -1219,6 +1377,7 @@ fn entry_row(
|
||||
app: &mut SolveApp,
|
||||
ui: &mut egui::Ui,
|
||||
comp: u64,
|
||||
path: Option<&[u64]>,
|
||||
folder: &'static str,
|
||||
e: &Entry,
|
||||
depth: usize,
|
||||
@@ -1255,6 +1414,9 @@ fn entry_row(
|
||||
}
|
||||
if r.double && folder == "sketches" {
|
||||
if let Ok(id) = e.key.parse() {
|
||||
if let Some(path) = path {
|
||||
let _ = app.run("component.activate", json!({"component": comp,"occurrence":path.last(),"path":path}));
|
||||
}
|
||||
app.edit_sketch(id);
|
||||
}
|
||||
} else if r.clicked {
|
||||
@@ -1367,9 +1529,29 @@ fn apply_drop(app: &mut SolveApp, d: DropAction) {
|
||||
/// Move/Copy of an occurrence: offsets and a rotation about Z, pending until Capture Position.
|
||||
fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let Some(mut m) = app.tree.occurrence_move.clone() else { return };
|
||||
if !m.path.is_empty()
|
||||
&& let Err(error) = app.session.occurrence_placement(&m.path)
|
||||
{
|
||||
app.tree.occurrence_move = None;
|
||||
app.set_status(error.to_string(), true);
|
||||
return;
|
||||
}
|
||||
if m.baseline.is_none() {
|
||||
m.had_pending = app.session.pending_moves.contains_key(&m.occurrence);
|
||||
m.baseline = app
|
||||
.session
|
||||
.pending_moves
|
||||
.get(&m.occurrence)
|
||||
.copied()
|
||||
.or_else(|| app.session.doc.occurrences.iter().find(|o| o.id == m.occurrence).map(|o| o.transform));
|
||||
}
|
||||
let ctx = ui.ctx().clone();
|
||||
if m.pivot.is_none() {
|
||||
m.pivot = occurrence_origin(app, m.occurrence);
|
||||
m.pivot = if m.path.is_empty() {
|
||||
occurrence_origin(app, m.occurrence)
|
||||
} else {
|
||||
app.session.occurrence_placement(&m.path).ok().map(|p| solvecraft_engine::doc::apply_point(&p.world, solvecraft_engine::geom::Vec3::ZERO))
|
||||
};
|
||||
}
|
||||
let name = app.session.doc.occurrences.iter().find(|o| o.id == m.occurrence).map(|o| o.name.clone()).unwrap_or_default();
|
||||
let mut open = true;
|
||||
@@ -1383,28 +1565,33 @@ fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.show(&ctx, |ui| {
|
||||
if !m.path.is_empty() && app.session.occurrence_scope_count(&m.path).is_ok_and(|n| n > 1) {
|
||||
ui.label("This child is shared. Position changes apply to every copy of its parent.");
|
||||
}
|
||||
egui::Grid::new("sc_occ_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
|
||||
for (k, axis) in ["X distance", "Y distance", "Z distance"].iter().enumerate() {
|
||||
ui.label(*axis);
|
||||
if let Some(v) = m.translate.get_mut(k) {
|
||||
ui.add(egui::DragValue::new(v).speed(0.5).suffix(" mm"));
|
||||
let response = ui.add(egui::DragValue::new(v).speed(0.5).suffix(" mm"));
|
||||
crate::scenario::publish_handle(&format!("occurrence-move:distance:{k}"), response.rect.center());
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
for (k, axis) in ["X angle", "Y angle", "Z angle"].iter().enumerate() {
|
||||
ui.label(*axis);
|
||||
if let Some(v) = m.angles.get_mut(k) {
|
||||
ui.add(egui::DragValue::new(v).speed(1.0).suffix(" deg"));
|
||||
let response = ui.add(egui::DragValue::new(v).speed(1.0).suffix(" deg"));
|
||||
crate::scenario::publish_handle(&format!("occurrence-move:angle:{k}"), response.rect.center());
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Capture Position").clicked() {
|
||||
if ui.button("Capture All Positions").clicked() {
|
||||
action = Some("capture");
|
||||
}
|
||||
if ui.button("Revert").clicked() {
|
||||
if ui.button("Revert All Positions").clicked() {
|
||||
action = Some("revert");
|
||||
}
|
||||
});
|
||||
@@ -1423,13 +1610,21 @@ fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
app.tree.occurrence_move = None;
|
||||
}
|
||||
None if !open => {
|
||||
let _ = app.run("component.revert_position", json!({}));
|
||||
revert_occurrence_dialog(app, &m);
|
||||
app.tree.occurrence_move = None;
|
||||
}
|
||||
None => app.tree.occurrence_move = Some(m),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn revert_occurrence_dialog(app: &mut SolveApp, m: &OccMove) {
|
||||
if m.had_pending {
|
||||
let _ = app.run("occurrence.move", json!({"occurrence":m.occurrence,"base_transform":m.baseline,"capture":false}));
|
||||
} else {
|
||||
let _ = app.run("component.revert_position", json!({"occurrences":[m.occurrence]}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The pending move: turned about X, then Y, then Z through the starting origin, then moved.
|
||||
pub fn apply_occurrence_move(app: &mut SolveApp, m: &OccMove) {
|
||||
use crate::gizmo;
|
||||
@@ -1438,10 +1633,14 @@ pub fn apply_occurrence_move(app: &mut SolveApp, m: &OccMove) {
|
||||
let r = gizmo::mul(&gizmo::mul(&gizmo::rotation(Vec3::Z, a[2]), &gizmo::rotation(Vec3::Y, a[1])), &gizmo::rotation(Vec3::X, a[0]));
|
||||
let (axis, angle) = gizmo::axis_angle(&r);
|
||||
let o = m.pivot.unwrap_or(Vec3::ZERO);
|
||||
let _ = app.session.execute("component.revert_position", &json!({}));
|
||||
let baseline = m.baseline.or_else(|| app.session.doc.occurrences.iter().find(|o| o.id == m.occurrence).map(|o| o.transform));
|
||||
let _ = app.run(
|
||||
"occurrence.move",
|
||||
json!({ "occurrence": m.occurrence, "translate": m.translate, "axis": [axis.x, axis.y, axis.z], "angle": angle, "origin": [o.x, o.y, o.z], "capture": false }),
|
||||
if m.path.is_empty() {
|
||||
json!({ "occurrence": m.occurrence, "base_transform": baseline, "translate": m.translate, "axis": [axis.x, axis.y, axis.z], "angle": angle, "origin": [o.x, o.y, o.z], "capture": false })
|
||||
} else {
|
||||
json!({ "occurrence": m.occurrence, "path": m.path, "space": "world", "base_transform": baseline, "translate": m.translate, "axis": [axis.x, axis.y, axis.z], "angle": angle, "origin": [o.x, o.y, o.z], "capture": false })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1617,46 +1816,3 @@ fn occurrence_origin(app: &SolveApp, occurrence: u64) -> Option<solvecraft_engin
|
||||
let own = app.session.pending_moves.get(&occurrence).copied().unwrap_or(o.transform);
|
||||
Some(solvecraft_engine::doc::apply_point(&solvecraft_engine::doc::mat_mul(&parent, &own), solvecraft_engine::geom::Vec3::ZERO))
|
||||
}
|
||||
|
||||
/// Redefine Sketch Plane: pick an origin or construction plane, or the selected planar face.
|
||||
fn redefine_panel(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(id) = app.tree.redefine else { return };
|
||||
let name = app.session.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
let mut planes: Vec<String> = vec!["XY".into(), "XZ".into(), "YZ".into()];
|
||||
planes.extend(app.session.doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })).map(|f| f.name.clone()));
|
||||
let face = app.session.selection.iter().find_map(|s| if let Sel::Face { point, .. } = s { Some(*point) } else { None });
|
||||
let mut open = true;
|
||||
let mut pick: Option<Value> = None;
|
||||
crate::frame::window(ctx, format!("Redefine Sketch Plane: {name}"), crate::frame::Width::Normal)
|
||||
.id(egui::Id::new("sc_redefine"))
|
||||
.pivot(egui::Align2::LEFT_TOP)
|
||||
.default_pos(panel_pos(app))
|
||||
.open(&mut open)
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.show(ctx, |ui| {
|
||||
ui.label(RichText::new("Move the sketch to").color(Tokens::get().text_dim));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for p in &planes {
|
||||
if ui.button(p).clicked() {
|
||||
pick = Some(json!(p));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let b = ui.add_enabled(face.is_some(), egui::Button::new("Selected face"));
|
||||
if b.clicked()
|
||||
&& let Some(f) = face
|
||||
{
|
||||
pick = Some(json!({ "face": [f.x, f.y, f.z] }));
|
||||
}
|
||||
b.on_disabled_hover_text("Select a planar face in the viewport first");
|
||||
});
|
||||
if let Some(plane) = pick {
|
||||
if app.run("sketch.redefine", json!({ "sketch": id, "plane": plane })).is_ok() {
|
||||
app.tree.redefine = None;
|
||||
}
|
||||
} else if !open {
|
||||
app.tree.redefine = None;
|
||||
}
|
||||
}
|
||||
+430
-60
@@ -23,8 +23,26 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
let (at, normal) = match first {
|
||||
Sel::Profile { sketch, index } => {
|
||||
let ss = st.sketch(*sketch)?;
|
||||
let plane = profile_plane(app, *sketch)?;
|
||||
let p = ss.profiles.get(*index)?;
|
||||
(ss.plane.to_world(p.region.interior_point()), Some(ss.plane.normal()))
|
||||
let centre = if matches!(d.kind, Kind::Extrude { .. }) {
|
||||
let regions: Vec<_> = d
|
||||
.inputs
|
||||
.first()?
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
if let Sel::Profile { sketch: id, index } = item { (*id == *sketch).then(|| ss.profiles.get(*index)).flatten() } else { None }
|
||||
})
|
||||
.map(|p| p.region.clone())
|
||||
.collect();
|
||||
let regions = solvecraft_engine::sketch::merge_regions(®ions);
|
||||
let points: Vec<_> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
|
||||
points.iter().fold(solvecraft_engine::geom::Vec2::default(), |sum, p| sum + *p) * (1.0 / points.len().max(1) as f64)
|
||||
} else {
|
||||
p.region.interior_point()
|
||||
};
|
||||
(plane.to_world(centre), Some(plane.normal()))
|
||||
}
|
||||
Sel::Face { body, index, point } => (*point, crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| n)),
|
||||
Sel::Edge { body, index, point } => (*point, st.body(body).and_then(|b| edge_bisector(&b.mesh(), *index, *point))),
|
||||
@@ -43,13 +61,15 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(match d.kind {
|
||||
Kind::Extrude { .. }
|
||||
| Kind::Fillet { .. }
|
||||
| Kind::Section { .. }
|
||||
| Kind::Hole { .. }
|
||||
| Kind::OffsetPlane { .. }
|
||||
| Kind::OffsetFaces { .. } => (at, normal),
|
||||
Some(match &d.kind {
|
||||
Kind::Extrude { start, .. } => {
|
||||
let n = normal?;
|
||||
let plane = solvecraft_engine::geom::Plane::from_normal(at, n)?;
|
||||
let from = reference_height(app, d, "From Object", &plane, at).unwrap_or(0.0);
|
||||
let offset = if start.trim().is_empty() { 0.0 } else { app.session.doc.eval(start, ValueKind::Length).ok()? };
|
||||
(at + n * (from + offset), Some(n))
|
||||
}
|
||||
Kind::Fillet { .. } | Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal),
|
||||
// Shell thickness grows into the body.
|
||||
Kind::Shell { .. } => (at, normal.map(|n| -n)),
|
||||
Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)),
|
||||
@@ -59,6 +79,25 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
})
|
||||
}
|
||||
|
||||
fn profile_plane(app: &SolveApp, sketch: u64) -> Option<solvecraft_engine::geom::Plane> {
|
||||
let component = app.session.doc.feature(sketch)?.component;
|
||||
let state = app.session.model.state();
|
||||
let plane = state.sketch(sketch)?.plane;
|
||||
let frame = crate::dialogs::feature_frame(&app.session, component);
|
||||
solvecraft_engine::geom::Plane::new(
|
||||
solvecraft_engine::doc::apply_point(&frame, plane.origin),
|
||||
solvecraft_engine::doc::apply_vector(&frame, plane.x),
|
||||
solvecraft_engine::doc::apply_vector(&frame, plane.y),
|
||||
)
|
||||
}
|
||||
|
||||
fn reference_height(app: &SolveApp, d: &Dialog, label: &str, plane: &solvecraft_engine::geom::Plane, centre: Vec3) -> Option<f64> {
|
||||
let point = crate::dialogs::object_point(&d.inputs, label)?;
|
||||
let p = point.as_array()?;
|
||||
let p = Vec3::new(p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2)?.as_f64()?);
|
||||
Some(solvecraft_engine::doc::object_height(&app.session.world_state(), plane, centre, p))
|
||||
}
|
||||
|
||||
/// The outward direction between the two faces at an edge (where a fillet's radius arrow
|
||||
/// points).
|
||||
fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
|
||||
@@ -155,22 +194,57 @@ fn offset_plane_quad(proj: &Proj, c: Vec3, n: Vec3, h: f64) -> Option<Vec<Pos2>>
|
||||
#[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,
|
||||
let to_span = dir.and_then(|n| {
|
||||
let plane = solvecraft_engine::geom::Plane::from_normal(base, n)?;
|
||||
reference_height(app, d, "To Object", &plane, base)
|
||||
});
|
||||
let all_span = if matches!(d.kind, Kind::Extrude { all: true, .. }) {
|
||||
dir.and_then(|n| {
|
||||
let state = app.session.world_state();
|
||||
let plane = match d.inputs.first()?.items.first()? {
|
||||
Sel::Profile { sketch, .. } => profile_plane(app, *sketch)?,
|
||||
_ => solvecraft_engine::geom::Plane::from_normal(base, n)?,
|
||||
};
|
||||
let targets: Vec<String> = d
|
||||
.extra
|
||||
.get("targets")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
|
||||
.unwrap_or_default();
|
||||
solvecraft_engine::doc::through_all_distance(&state, &plane, &targets).ok()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sign = match (to_span, &d.kind) {
|
||||
(Some(span), _) => {
|
||||
if span < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
(_, Kind::Extrude { direction: 1, .. }) => -1.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
let symmetric = matches!(d.kind, Kind::Extrude { direction: 2, .. });
|
||||
let symmetric = to_span.is_none() && matches!(d.kind, Kind::Extrude { direction: 2, .. });
|
||||
// Extrudes and moves go either way; radii and thicknesses stay positive.
|
||||
let signed =
|
||||
matches!(d.kind, Kind::Extrude { .. } | Kind::Move { .. } | Kind::Section { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. });
|
||||
let focus = std::mem::take(&mut d.focus);
|
||||
let half_height = app.cam.half_height();
|
||||
let axis = revolve_axis(app, d);
|
||||
if matches!(d.kind, Kind::Revolve { .. }) {
|
||||
if let Some(axis) = axis {
|
||||
draw_revolve(app, ui, painter, proj, d, base, bs, axis, focus);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 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);
|
||||
drag_arrow(ui, painter, proj, base, bs, -n, l2, true, egui::Id::new("sc_manipulator2"), half_height, distance2);
|
||||
}
|
||||
// An offset plane shows where it will be.
|
||||
if let (Kind::OffsetPlane { offset }, Some(n)) = (&d.kind, dir)
|
||||
@@ -184,16 +258,21 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
let mut dir = dir;
|
||||
// Extrude taper: a handle beside the arrow tip; dragging it sideways tilts the walls.
|
||||
let cam_right = app.cam.basis().0;
|
||||
if let (Kind::Extrude { direction: 0 | 1, distance, taper, .. }, Some(n)) = (&mut d.kind, dir) {
|
||||
if let (Kind::Extrude { direction: 0 | 1, distance, taper, taper_present, to_offset, .. }, Some(n)) = (&mut d.kind, dir) {
|
||||
let n = n * sign;
|
||||
if let Ok(l) = app.session.doc.eval(distance, ValueKind::Length)
|
||||
let span = to_span.map(|span| span.abs() + app.session.doc.eval(to_offset, ValueKind::Length).unwrap_or(0.0)).or(all_span);
|
||||
if let Ok(l) = span.map(Ok).unwrap_or_else(|| app.session.doc.eval(distance, ValueKind::Length))
|
||||
&& l.is_finite()
|
||||
&& l.abs() > 1e-9
|
||||
&& let Some(e) = (cam_right - n * cam_right.dot(n)).normalized()
|
||||
{
|
||||
let px = 2.0 * half_height / f64::from(proj.rect.height().max(1.0));
|
||||
let ang = app.session.doc.eval(taper, ValueKind::Angle).ok().filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let before = taper.clone();
|
||||
taper_handle(ui, painter, proj, base - e * (40.0 * px), n * l, -e, ang, taper);
|
||||
if *taper != before {
|
||||
*taper_present = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Kind::Hole { depth, opts, .. }, Some(n)) = (&mut d.kind, dir) {
|
||||
@@ -207,16 +286,24 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
dir = None;
|
||||
}
|
||||
let Some((label, kind, value)) = d.primary() else { return };
|
||||
let len = app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite());
|
||||
let len = all_span.or_else(|| {
|
||||
if to_span.is_some() && value.trim().is_empty() { Some(0.0) } else { app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite()) }
|
||||
});
|
||||
let before = value.clone();
|
||||
let mut box_at = bs + vec2(16.0, -30.0);
|
||||
if let (Some(axis), Some(angle)) = (axis, len.filter(|_| kind == ValueKind::Angle))
|
||||
&& let Some(at) = rotator(ui, painter, proj, base, axis, angle, value)
|
||||
{
|
||||
box_at = at + vec2(14.0, -12.0);
|
||||
}
|
||||
if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) {
|
||||
let n = n * sign;
|
||||
if let Some(ts) = drag_arrow(ui, painter, proj, base, bs, n, l, signed, egui::Id::new("sc_manipulator"), half_height, value) {
|
||||
let baseline = to_span.map(f64::abs).unwrap_or(0.0);
|
||||
let mut shown_value = format_len(l + baseline);
|
||||
let original_shown = shown_value.clone();
|
||||
let dragged = if to_span.is_some() { &mut shown_value } else { &mut *value };
|
||||
if let Some(ts) = drag_arrow(ui, painter, proj, base, bs, n, l + baseline, signed, egui::Id::new("sc_manipulator"), half_height, dragged) {
|
||||
if to_span.is_some()
|
||||
&& shown_value != original_shown
|
||||
&& let Ok(v) = app.session.doc.eval(&shown_value, ValueKind::Length)
|
||||
{
|
||||
*value = format_len(v - baseline);
|
||||
}
|
||||
if symmetric && let Some(bt) = proj.to_screen(base - n * l) {
|
||||
arrow(painter, bs, bt, t.manipulator);
|
||||
}
|
||||
@@ -241,6 +328,11 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
});
|
||||
});
|
||||
});
|
||||
if *value != before
|
||||
&& let Kind::Extrude { all, .. } = &mut d.kind
|
||||
{
|
||||
*all = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// A draggable arrow from `base` along `n` showing `l` (at least a short stub); dragging writes
|
||||
@@ -338,63 +430,341 @@ fn taper_handle(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, foot: V
|
||||
}
|
||||
|
||||
/// The revolve axis (a point on it and its unit direction).
|
||||
fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> {
|
||||
pub(crate) fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> {
|
||||
if !matches!(d.kind, Kind::Revolve { .. }) {
|
||||
return None;
|
||||
}
|
||||
crate::dialogs::axis_of(app, d.inputs.get(1)?.items.first()?)
|
||||
if let Some(selected) = d.inputs.get(1)?.items.first() {
|
||||
if let Sel::Axis { name } = selected
|
||||
&& matches!(name.as_str(), "X" | "Y" | "Z")
|
||||
&& let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()?
|
||||
{
|
||||
let component = app.session.doc.feature(*sketch)?.component;
|
||||
let frame = crate::dialogs::feature_frame(&app.session, component);
|
||||
let dir = match name.as_str() {
|
||||
"X" => Vec3::X,
|
||||
"Y" => Vec3::Y,
|
||||
_ => Vec3::Z,
|
||||
};
|
||||
return Some((solvecraft_engine::doc::apply_point(&frame, Vec3::ZERO), solvecraft_engine::doc::apply_vector(&frame, dir)));
|
||||
}
|
||||
if let Sel::Axis { name } = selected
|
||||
&& matches!(name.as_str(), "x" | "y")
|
||||
&& let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()?
|
||||
{
|
||||
let plane = profile_plane(app, *sketch)?;
|
||||
return Some((plane.origin, if name == "x" { plane.x } else { plane.y }));
|
||||
}
|
||||
return crate::dialogs::axis_of(app, selected);
|
||||
}
|
||||
let Kind::Revolve { axis: Some(axis), .. } = &d.kind else { return None };
|
||||
let vector = |key: &str| -> Option<Vec3> {
|
||||
let p = axis.get(key)?.as_array()?;
|
||||
Some(Vec3::new(p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2)?.as_f64()?))
|
||||
};
|
||||
Some((vector("origin")?, vector("dir")?.normalized()?))
|
||||
}
|
||||
|
||||
/// The revolve angle rotator: an arc from the profile around the axis with a handle at its end
|
||||
/// that drags the angle (5° steps; Alt or Ctrl: free). Returns the handle's screen position.
|
||||
/// Draw angular extents in the same clockwise sense as the modeled solid.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rotator(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, p0: Vec3, (o, a): (Vec3, Vec3), angle: f64, value: &mut String) -> Option<Pos2> {
|
||||
let t = Tokens::get();
|
||||
let c = o + a * (p0 - o).dot(a);
|
||||
let r = (p0 - c).len();
|
||||
if r < 1e-9 {
|
||||
return None;
|
||||
fn draw_revolve(
|
||||
app: &SolveApp,
|
||||
ui: &mut egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
proj: &Proj,
|
||||
d: &mut Dialog,
|
||||
base: Vec3,
|
||||
bs: Pos2,
|
||||
axis: (Vec3, Vec3),
|
||||
focus: bool,
|
||||
) {
|
||||
let to_mode = d.inputs.iter().any(|input| input.label == "To Object");
|
||||
let extent = revolve_display_extent(app, d, axis).unwrap_or(solvecraft_engine::doc::RevolveExtent {
|
||||
start: 0.0,
|
||||
sweep: f64::NAN,
|
||||
rotation_axis: axis.1,
|
||||
display_start: 0.0,
|
||||
curved_to: false,
|
||||
});
|
||||
let mut box_at = bs + vec2(16.0, -30.0);
|
||||
if !extent.curved_to && extent.sweep.is_finite() {
|
||||
let (origin, direction) = axis;
|
||||
let centre = origin + direction * (base - origin).dot(direction);
|
||||
let radius = (base - centre).len();
|
||||
if radius > 1e-9 {
|
||||
let e1 = (base - centre) / radius;
|
||||
let e2 = extent.rotation_axis.cross(e1);
|
||||
let handed = match revolve_convention(d) {
|
||||
solvecraft_engine::doc::RevolveConvention::LegacyAxisStart => -direction.dot(extent.rotation_axis),
|
||||
solvecraft_engine::doc::RevolveConvention::SketchNormal => 1.0,
|
||||
};
|
||||
let at = |angle: f64| centre + (e1 * angle.cos() + e2 * angle.sin()) * radius;
|
||||
let points: Vec<_> = (0..=48).filter_map(|i| proj.to_screen(at(extent.display_start + extent.sweep * i as f64 / 48.0))).collect();
|
||||
painter.add(egui::Shape::line(points, Stroke::new(2.0, Tokens::get().manipulator)));
|
||||
if let Some(end) = proj.to_screen(at(extent.display_start + extent.sweep)) {
|
||||
box_at = end + vec2(14.0, -12.0);
|
||||
}
|
||||
if !to_mode {
|
||||
let symmetric = !d.extra.contains_key("angle2") && d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric");
|
||||
let other_angle = d
|
||||
.extra
|
||||
.get("angle2")
|
||||
.and_then(|value| value.as_str())
|
||||
.and_then(|value| app.session.doc.eval(value, ValueKind::Angle).ok())
|
||||
.unwrap_or(0.0);
|
||||
let range = if extent.sweep < 0.0 { (-std::f64::consts::TAU, 0.0) } else { (0.0, std::f64::consts::TAU) };
|
||||
if let Kind::Revolve { angle, .. } = &mut d.kind {
|
||||
let boundary = extent.display_start + extent.sweep;
|
||||
if let Some(end) = rotation_handle(
|
||||
ui,
|
||||
painter,
|
||||
proj,
|
||||
centre,
|
||||
e1,
|
||||
e2,
|
||||
direction,
|
||||
radius,
|
||||
boundary,
|
||||
if symmetric { 1.0 / (1.0 - handed * 0.5) } else { 1.0 },
|
||||
if symmetric { 0.0 } else { -other_angle * (1.0 - handed) },
|
||||
(range.0 - other_angle, range.1 - other_angle),
|
||||
"revolve",
|
||||
angle,
|
||||
) {
|
||||
box_at = end + vec2(14.0, -12.0);
|
||||
}
|
||||
if symmetric {
|
||||
rotation_handle(
|
||||
ui,
|
||||
painter,
|
||||
proj,
|
||||
centre,
|
||||
e1,
|
||||
e2,
|
||||
direction,
|
||||
radius,
|
||||
extent.display_start,
|
||||
-2.0 * handed,
|
||||
0.0,
|
||||
range,
|
||||
"revolve2",
|
||||
angle,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(other) = d.extra.get_mut("angle2") {
|
||||
let mut value = other.as_str().map(str::to_owned).unwrap_or_else(|| other.to_string());
|
||||
let before = value.clone();
|
||||
let first_angle = extent.sweep - other_angle;
|
||||
rotation_handle(
|
||||
ui,
|
||||
painter,
|
||||
proj,
|
||||
centre,
|
||||
e1,
|
||||
e2,
|
||||
direction,
|
||||
radius,
|
||||
extent.display_start,
|
||||
-handed,
|
||||
0.0,
|
||||
(range.0 - first_angle, range.1 - first_angle),
|
||||
"revolve2",
|
||||
&mut value,
|
||||
);
|
||||
if value != before {
|
||||
*other = serde_json::Value::String(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let e1 = (p0 - c) / r;
|
||||
let e2 = a.cross(e1);
|
||||
let at = |th: f64| c + (e1 * th.cos() + e2 * th.sin()) * r;
|
||||
let n = 48;
|
||||
let pts: Vec<Pos2> = (0..=n).filter_map(|i| proj.to_screen(at(angle * i as f64 / n as f64))).collect();
|
||||
let end = proj.to_screen(at(angle))?;
|
||||
let handle = egui::Rect::from_center_size(end, vec2(20.0, 20.0));
|
||||
let resp = ui.interact(handle, egui::Id::new("sc_rotator"), egui::Sense::drag());
|
||||
let hot = resp.hovered() || resp.dragged();
|
||||
let col = if hot { t.accent } else { t.manipulator };
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(2.0, col)));
|
||||
if let (Some(cs), Some(ps)) = (proj.to_screen(c), proj.to_screen(p0)) {
|
||||
painter.add(egui::Shape::dashed_line(&[cs, ps], Stroke::new(1.0, col), 4.0, 3.0));
|
||||
}
|
||||
painter.circle(end, if hot { 7.0 } else { 5.5 }, col, Stroke::new(1.5, Color32::WHITE));
|
||||
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| {
|
||||
if to_mode {
|
||||
let text = if extent.curved_to {
|
||||
"To surface".into()
|
||||
} else if extent.sweep.is_finite() {
|
||||
format!("Sweep {:.1}°", extent.sweep.to_degrees())
|
||||
} else {
|
||||
"Choose target".into()
|
||||
};
|
||||
ui.label(text).on_hover_text("Determined by To Object. Choose Angle to edit the angular extent.");
|
||||
} else if let Kind::Revolve { angle, .. } = &mut d.kind {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Angle").color(Tokens::get().text_dim).size(11.5));
|
||||
let id = egui::Id::new("sc_canvas_value");
|
||||
let response = ui.add(egui::TextEdit::singleline(angle).id(id).desired_width(72.0));
|
||||
crate::params_dialog::complete(ui, &response, angle);
|
||||
if focus {
|
||||
response.request_focus();
|
||||
let n = angle.chars().count();
|
||||
let mut state = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default();
|
||||
state
|
||||
.cursor
|
||||
.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
|
||||
state.store(ui.ctx(), id);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn revolve_convention(d: &Dialog) -> solvecraft_engine::doc::RevolveConvention {
|
||||
d.extra
|
||||
.get("convention")
|
||||
.and_then(|value| serde_json::from_value(value.clone()).ok())
|
||||
.unwrap_or(solvecraft_engine::doc::RevolveConvention::SketchNormal)
|
||||
}
|
||||
|
||||
fn revolve_display_extent(app: &SolveApp, d: &Dialog, axis: (Vec3, Vec3)) -> Option<solvecraft_engine::doc::RevolveExtent> {
|
||||
let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()? else { return None };
|
||||
let state = app.session.world_state();
|
||||
let ss = state.sketch(*sketch)?;
|
||||
let regions: Vec<_> = d
|
||||
.inputs
|
||||
.first()?
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Sel::Profile { sketch: id, index } if id == sketch => ss.profiles.get(*index).map(|profile| profile.region.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let regions = solvecraft_engine::sketch::merge_regions(®ions);
|
||||
let plane = profile_plane(app, *sketch)?;
|
||||
let to_mode = d.inputs.iter().any(|input| input.label == "To Object");
|
||||
let to = if to_mode {
|
||||
let point = crate::dialogs::object_point(&d.inputs, "To Object")?;
|
||||
Some(Vec3::new(point.get(0)?.as_f64()?, point.get(1)?.as_f64()?, point.get(2)?.as_f64()?))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Kind::Revolve { angle, .. } = &d.kind else { return None };
|
||||
let angles = if to_mode {
|
||||
(0.0, None)
|
||||
} else {
|
||||
let angle = app.session.doc.eval(angle, ValueKind::Angle).ok()?;
|
||||
let other = d.extra.get("angle2").map(|value| app.session.doc.eval(value.as_str().unwrap_or_default(), ValueKind::Angle)).transpose().ok()?;
|
||||
(angle, other)
|
||||
};
|
||||
solvecraft_engine::doc::revolve_extent_with_convention(
|
||||
&state,
|
||||
&plane,
|
||||
®ions,
|
||||
axis,
|
||||
angles,
|
||||
d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric"),
|
||||
to,
|
||||
revolve_convention(d),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rotation_handle(
|
||||
ui: &mut egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
proj: &Proj,
|
||||
centre: Vec3,
|
||||
e1: Vec3,
|
||||
e2: Vec3,
|
||||
axis: Vec3,
|
||||
radius: f64,
|
||||
boundary: f64,
|
||||
scale: f64,
|
||||
offset: f64,
|
||||
range: (f64, f64),
|
||||
name: &str,
|
||||
value: &mut String,
|
||||
) -> Option<Pos2> {
|
||||
let end = proj.to_screen(centre + (e1 * boundary.cos() + e2 * boundary.sin()) * radius)?;
|
||||
crate::scenario::publish_handle(name, end);
|
||||
let response = ui.interact(egui::Rect::from_center_size(end, vec2(20.0, 20.0)), egui::Id::new(("sc_rotator", name)), egui::Sense::drag());
|
||||
let hot = response.hovered() || response.dragged();
|
||||
let color = if hot { Tokens::get().accent } else { Tokens::get().manipulator };
|
||||
painter.circle(end, if hot { 7.0 } else { 5.5 }, color, Stroke::new(1.5, Color32::WHITE));
|
||||
if hot {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
if response.dragged()
|
||||
&& let Some(pointer) = response.interact_pointer_pos()
|
||||
{
|
||||
let (ro, rd) = proj.ray(p);
|
||||
let den = rd.dot(a);
|
||||
if den.abs() > 1e-9 {
|
||||
let q = ro + rd * ((c - ro).dot(a) / den) - c;
|
||||
let mut th = q.dot(e2).atan2(q.dot(e1)).to_degrees().rem_euclid(360.0);
|
||||
let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command);
|
||||
if !free {
|
||||
th = (th / 5.0).round() * 5.0;
|
||||
let (origin, direction) = proj.ray(pointer);
|
||||
let den = direction.dot(axis);
|
||||
let (a, b) = ((range.0 - offset) / scale, (range.1 - offset) / scale);
|
||||
let bounds = (a.min(b), a.max(b));
|
||||
// Near edge-on, a plane intersection magnifies subpixel pointer error.
|
||||
let raw = if den.abs() > 0.05 {
|
||||
let point = origin + direction * ((centre - origin).dot(axis) / den) - centre;
|
||||
Some(point.dot(e2).atan2(point.dot(e1)))
|
||||
} else {
|
||||
projected_rotation_angle(proj, pointer, centre, e1, e2, radius, boundary, bounds)
|
||||
};
|
||||
if let Some(raw) = raw {
|
||||
let boundary_angle = (-4..=4)
|
||||
.map(|k| raw + k as f64 * std::f64::consts::TAU)
|
||||
.filter(|angle| *angle >= bounds.0 - 1e-4 && *angle <= bounds.1 + 1e-4)
|
||||
.min_by(|a, b| (a - boundary).abs().total_cmp(&(b - boundary).abs()))
|
||||
.unwrap_or_else(|| raw.clamp(bounds.0, bounds.1));
|
||||
let mut angle = (boundary_angle.clamp(bounds.0, bounds.1) * scale + offset).to_degrees();
|
||||
if !ui.input(|input| input.modifiers.alt || input.modifiers.ctrl || input.modifiers.command) {
|
||||
angle = (angle / 5.0).round() * 5.0;
|
||||
}
|
||||
if th < 0.5 {
|
||||
th = 360.0;
|
||||
if angle.abs() < 0.05 {
|
||||
angle = 0.0;
|
||||
}
|
||||
let s = format!("{th:.1}");
|
||||
*value = format!("{} deg", s.trim_end_matches('0').trim_end_matches('.'));
|
||||
let text = format!("{angle:.1}");
|
||||
*value = format!("{} deg", text.trim_end_matches('0').trim_end_matches('.'));
|
||||
}
|
||||
}
|
||||
Some(end)
|
||||
}
|
||||
|
||||
/// An edge-on ring has two equally valid depths; retain the branch nearest the current handle.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn projected_rotation_angle(
|
||||
proj: &Proj,
|
||||
pointer: Pos2,
|
||||
centre: Vec3,
|
||||
e1: Vec3,
|
||||
e2: Vec3,
|
||||
radius: f64,
|
||||
current: f64,
|
||||
(lo, hi): (f64, f64),
|
||||
) -> Option<f64> {
|
||||
let distance = |angle: f64| {
|
||||
proj.to_screen(centre + (e1 * angle.cos() + e2 * angle.sin()) * radius)
|
||||
.map(|point| f64::from(point.distance_sq(pointer)))
|
||||
.unwrap_or(f64::INFINITY)
|
||||
};
|
||||
let step = (hi - lo) / 720.0;
|
||||
let mut best = None;
|
||||
for i in 0..=720 {
|
||||
let angle = lo + step * i as f64;
|
||||
let score = distance(angle);
|
||||
if !score.is_finite() {
|
||||
continue;
|
||||
}
|
||||
if best.is_none_or(|(old_angle, old_score): (f64, f64)| {
|
||||
score < old_score - 1e-4 || (score - old_score).abs() <= 1e-4 && (angle - current).abs() < (old_angle - current).abs()
|
||||
}) {
|
||||
best = Some((angle, score));
|
||||
}
|
||||
}
|
||||
let (angle, _) = best?;
|
||||
let (mut a, mut b) = ((angle - step).max(lo), (angle + step).min(hi));
|
||||
for _ in 0..24 {
|
||||
let (left, right) = (a + (b - a) / 3.0, b - (b - a) / 3.0);
|
||||
if distance(left) <= distance(right) {
|
||||
b = right;
|
||||
} else {
|
||||
a = left;
|
||||
}
|
||||
}
|
||||
Some((a + b) / 2.0)
|
||||
}
|
||||
fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32) {
|
||||
let v = b - a;
|
||||
let l = v.length();
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Structured configuration rule editing and non-mutating row preview.
|
||||
use crate::SolveApp;
|
||||
use egui::{RichText, Ui};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::doc::config::{Column, ConfigRule, RuleCondition, RuleEffect, RuleOperator};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Editor {
|
||||
rule: ConfigRule,
|
||||
row: String,
|
||||
preview: Option<Value>,
|
||||
|
||||
preview_revision: u64,
|
||||
pub(super) drafts: std::collections::BTreeMap<(String, String), String>,
|
||||
pub(super) table: Option<(u64, Value)>,
|
||||
}
|
||||
impl Default for Editor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
rule: ConfigRule {
|
||||
id: String::new(),
|
||||
name: "New rule".into(),
|
||||
enabled: true,
|
||||
length_unit: None,
|
||||
condition: RuleCondition::Always,
|
||||
effect: RuleEffect::DriveParameter { parameter: String::new(), expression: "10 mm".into() },
|
||||
},
|
||||
row: String::new(),
|
||||
preview: None,
|
||||
|
||||
preview_revision: 0,
|
||||
drafts: Default::default(),
|
||||
table: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn choose(ui: &mut Ui, id: &str, value: &mut String, options: &[String]) {
|
||||
egui::ComboBox::from_id_salt(id).selected_text(if value.is_empty() { "Choose…" } else { value.as_str() }).width(130.0).show_ui(ui, |ui| {
|
||||
for option in options {
|
||||
ui.selectable_value(value, option.clone(), option);
|
||||
}
|
||||
});
|
||||
}
|
||||
fn expression(ui: &mut Ui, label: &str, value: &mut String) {
|
||||
ui.label(label);
|
||||
ui.add(egui::TextEdit::singleline(value).desired_width(160.0));
|
||||
}
|
||||
|
||||
pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option<String>, editor: &mut Editor) {
|
||||
if editor.preview_revision != app.session.revision {
|
||||
editor.preview = None;
|
||||
}
|
||||
let rules = app.session.doc.configs.rules.clone();
|
||||
let parameters: Vec<String> =
|
||||
app.session.doc.configs.columns.iter().filter_map(|c| if let Column::Param(n) = c { Some(n.clone()) } else { None }).collect();
|
||||
let all_parameters: Vec<String> = app.session.doc.all_param_exprs().into_iter().map(|p| p.0).collect();
|
||||
let features: Vec<(u64, String)> = app.session.doc.features.iter().map(|f| (f.id, f.name.clone())).collect();
|
||||
let suppression_features: Vec<(u64, String)> =
|
||||
features.iter().filter(|(id, _)| app.session.doc.configs.columns.contains(&Column::Suppress(*id))).cloned().collect();
|
||||
ui.separator();
|
||||
ui.label(RichText::new("Configuration rules").strong());
|
||||
ui.add(egui::Label::new("Rules run from top to bottom. Matched drives change effective values.").wrap());
|
||||
ui.add(egui::Label::new("Matched restrictions check the final values.").wrap());
|
||||
for (index, rule) in rules.iter().enumerate() {
|
||||
ui.push_id(&rule.id, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let mut enabled = rule.enabled;
|
||||
if ui.checkbox(&mut enabled, "").on_hover_text("Enable rule").changed() {
|
||||
let mut r = rule.clone();
|
||||
r.enabled = enabled;
|
||||
*error = app.run("config.rule", json!({"rule":r})).err();
|
||||
}
|
||||
ui.add(egui::Label::new(&rule.name).truncate());
|
||||
if ui.small_button("Edit").clicked() {
|
||||
editor.rule = rule.clone();
|
||||
}
|
||||
if ui.add_enabled(index > 0, egui::Button::new("↑").small()).on_hover_text("Move earlier").clicked() {
|
||||
*error = app.run("config.rule", json!({"id":rule.id,"index":index-1})).err();
|
||||
}
|
||||
if ui.add_enabled(index + 1 < rules.len(), egui::Button::new("↓").small()).on_hover_text("Move later").clicked() {
|
||||
*error = app.run("config.rule", json!({"id":rule.id,"index":index+1})).err();
|
||||
}
|
||||
if ui.small_button("Delete").clicked() {
|
||||
*error = app.run("config.rule", json!({"id":rule.id,"delete":true})).err();
|
||||
}
|
||||
});
|
||||
ui.label(match &rule.effect {
|
||||
RuleEffect::DriveParameter { parameter, expression } => format!("Drives {parameter} = {expression}"),
|
||||
RuleEffect::DriveSuppression { feature, value } => format!(
|
||||
"Drives suppression {} = {value}",
|
||||
features.iter().find(|f| f.0 == *feature).map(|f| f.1.as_str()).unwrap_or("missing feature")
|
||||
),
|
||||
RuleEffect::ParameterRange { parameter, minimum, maximum } => format!("Restricts {parameter}: {minimum} through {maximum}"),
|
||||
RuleEffect::AllowParameterValues { parameter, expressions } => format!("Restricts {parameter}: {}", expressions.join(", ")),
|
||||
});
|
||||
});
|
||||
}
|
||||
ui.collapsing("Add or edit rule", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
expression(ui, "Rule name", &mut editor.rule.name);
|
||||
ui.checkbox(&mut editor.rule.enabled, "Enabled");
|
||||
if ui.small_button("New").clicked() {
|
||||
editor.rule = Editor::default().rule;
|
||||
}
|
||||
});
|
||||
let mut length_unit = editor.rule.length_unit.clone().unwrap_or_else(|| app.session.doc.units.clone());
|
||||
let old_unit = length_unit.clone();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Length units");
|
||||
choose(ui, "rule_length_units", &mut length_unit, &["mm".into(), "cm".into(), "m".into(), "in".into(), "ft".into()]);
|
||||
});
|
||||
if length_unit != old_unit {
|
||||
editor.rule.length_unit = Some(length_unit);
|
||||
}
|
||||
condition_editor(ui, &mut editor.rule.condition, &all_parameters, &features, 1);
|
||||
let mut effect_kind = match editor.rule.effect {
|
||||
RuleEffect::DriveParameter { .. } => "Drive parameter",
|
||||
RuleEffect::DriveSuppression { .. } => "Drive suppression",
|
||||
RuleEffect::ParameterRange { .. } => "Range",
|
||||
RuleEffect::AllowParameterValues { .. } => "Allowed values",
|
||||
}
|
||||
.to_string();
|
||||
let prior = effect_kind.clone();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Then");
|
||||
choose(
|
||||
ui,
|
||||
"rule_effect_kind",
|
||||
&mut effect_kind,
|
||||
&["Drive parameter".into(), "Drive suppression".into(), "Range".into(), "Allowed values".into()],
|
||||
);
|
||||
});
|
||||
if prior != effect_kind {
|
||||
let parameter = parameters.first().cloned().unwrap_or_default();
|
||||
editor.rule.effect = match effect_kind.as_str() {
|
||||
"Drive suppression" => RuleEffect::DriveSuppression { feature: suppression_features.first().map(|f| f.0).unwrap_or(0), value: true },
|
||||
"Range" => RuleEffect::ParameterRange { parameter, minimum: "0 mm".into(), maximum: "100 mm".into() },
|
||||
"Allowed values" => RuleEffect::AllowParameterValues { parameter, expressions: vec!["10 mm".into(), "20 mm".into()] },
|
||||
_ => RuleEffect::DriveParameter { parameter, expression: "10 mm".into() },
|
||||
};
|
||||
}
|
||||
match &mut editor.rule.effect {
|
||||
RuleEffect::DriveParameter { parameter, expression: value } => {
|
||||
ui.horizontal(|ui| {
|
||||
choose(ui, "effect_parameter", parameter, ¶meters);
|
||||
expression(ui, "Value", value);
|
||||
});
|
||||
}
|
||||
RuleEffect::DriveSuppression { feature, value } => {
|
||||
ui.horizontal(|ui| {
|
||||
feature_choice(ui, "effect_feature", feature, &suppression_features);
|
||||
ui.checkbox(value, "Suppressed");
|
||||
});
|
||||
}
|
||||
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
|
||||
choose(ui, "range_parameter", parameter, ¶meters);
|
||||
ui.horizontal(|ui| {
|
||||
expression(ui, "Minimum", minimum);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
expression(ui, "Maximum", maximum);
|
||||
});
|
||||
}
|
||||
RuleEffect::AllowParameterValues { parameter, expressions } => {
|
||||
choose(ui, "allowed_parameter", parameter, ¶meters);
|
||||
let mut text = expressions.join("; ");
|
||||
ui.label("Allowed expressions, separated by semicolons");
|
||||
if ui.add(egui::TextEdit::singleline(&mut text).desired_width(300.0)).changed() {
|
||||
*expressions = text.split(';').map(|s| s.trim().to_string()).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui.add_enabled(!app.session.doc.configs.rows.is_empty(), egui::Button::new("Save Rule")).clicked() {
|
||||
let result = (|| -> Result<(), String> {
|
||||
let mut rule = editor.rule.clone();
|
||||
if rule.id.is_empty() {
|
||||
rule.id = (1..).map(|n| format!("Rule{n}")).find(|id| !rules.iter().any(|r| r.id == *id)).unwrap_or_default();
|
||||
}
|
||||
app.run("config.rule", json!({"rule":rule.clone()}))?;
|
||||
editor.rule = app.session.doc.configs.rules.iter().find(|existing| existing.id == rule.id).cloned().unwrap_or(rule);
|
||||
Ok(())
|
||||
})();
|
||||
*error = result.err();
|
||||
}
|
||||
});
|
||||
let rows: Vec<String> = app.session.doc.configs.rows.iter().map(|r| r.name.clone()).collect();
|
||||
if !rows.contains(&editor.row) {
|
||||
editor.row = app.session.doc.configs.active.clone().or_else(|| rows.first().cloned()).unwrap_or_default();
|
||||
}
|
||||
let previous_row = editor.row.clone();
|
||||
ui.horizontal(|ui| {
|
||||
choose(ui, "preview_configuration", &mut editor.row, &rows);
|
||||
if ui.add_enabled(!editor.row.is_empty(), egui::Button::new("Preview Row")).clicked() {
|
||||
match app.session.execute("config.preview", &json!({"row":editor.row})) {
|
||||
Ok(result) => {
|
||||
editor.preview = Some(result);
|
||||
editor.preview_revision = app.session.revision;
|
||||
*error = None;
|
||||
}
|
||||
Err(e) => {
|
||||
editor.preview = None;
|
||||
*error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if previous_row != editor.row {
|
||||
editor.preview = None;
|
||||
}
|
||||
if let Some(preview) = &editor.preview {
|
||||
ui.collapsing(format!("Preview: {}", preview["active"].as_str().unwrap_or("row")), |ui| {
|
||||
ui.label("Preview leaves the current design unchanged.");
|
||||
if let Some(styles) = preview["styles"].as_array() {
|
||||
for style in styles {
|
||||
ui.add(
|
||||
egui::Label::new(format!(
|
||||
"{}: {} (shared definition)",
|
||||
style["target"].as_str().unwrap_or("Appearance"),
|
||||
if style["dormant"].as_bool() == Some(true) { "dormant" } else { "resolved" }
|
||||
))
|
||||
.wrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(trace) = preview["trace"].as_array() {
|
||||
for entry in trace {
|
||||
ui.label(format!(
|
||||
"{} — {}",
|
||||
entry["name"].as_str().unwrap_or("rule"),
|
||||
if entry["matched"].as_bool() == Some(true) { "matched" } else { "skipped" }
|
||||
));
|
||||
if entry["matched"].as_bool() == Some(true)
|
||||
&& let Ok(effect) = serde_json::from_value::<RuleEffect>(entry["effect"].clone())
|
||||
{
|
||||
ui.label(match effect {
|
||||
RuleEffect::DriveParameter { parameter, expression } => format!("Driven: {parameter} = {expression}"),
|
||||
RuleEffect::DriveSuppression { feature, value } => format!(
|
||||
"Driven: {} is {}",
|
||||
features.iter().find(|f| f.0 == feature).map(|f| f.1.as_str()).unwrap_or("feature"),
|
||||
if value { "suppressed" } else { "unsuppressed" }
|
||||
),
|
||||
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
|
||||
format!("Restricted: {parameter}, {minimum} through {maximum}")
|
||||
}
|
||||
RuleEffect::AllowParameterValues { parameter, expressions } => {
|
||||
format!("Restricted: {parameter}, {}", expressions.join("; "))
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(parameters) = preview["parameters"].as_array() {
|
||||
for parameter in parameters {
|
||||
if let Some(name) = parameter["name"].as_str()
|
||||
&& all_parameters.iter().any(|p| p == name)
|
||||
{
|
||||
ui.label(format!("{name} = {}", parameter["expression"].as_str().unwrap_or("")));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
fn feature_choice(ui: &mut Ui, id: &str, feature: &mut u64, features: &[(u64, String)]) {
|
||||
egui::ComboBox::from_id_salt(id)
|
||||
.selected_text(features.iter().find(|f| f.0 == *feature).map(|f| f.1.as_str()).unwrap_or("Choose feature…"))
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, name) in features {
|
||||
ui.selectable_value(feature, *id, name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn condition_editor(ui: &mut Ui, condition: &mut RuleCondition, parameters: &[String], features: &[(u64, String)], depth: usize) {
|
||||
let mut kind = match condition {
|
||||
RuleCondition::Always => "Always",
|
||||
RuleCondition::Parameter { .. } => "Parameter",
|
||||
RuleCondition::Suppression { .. } => "Suppression",
|
||||
RuleCondition::All { .. } => "All",
|
||||
RuleCondition::Any { .. } => "Any",
|
||||
RuleCondition::Not { .. } => "Not",
|
||||
}
|
||||
.to_string();
|
||||
let previous = kind.clone();
|
||||
let mut choices = vec!["Always".into(), "Parameter".into(), "Suppression".into()];
|
||||
if depth < 8 {
|
||||
choices.extend(["All".into(), "Any".into(), "Not".into()]);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("When");
|
||||
choose(ui, "condition_kind", &mut kind, &choices);
|
||||
});
|
||||
if previous != kind {
|
||||
*condition = match kind.as_str() {
|
||||
"Parameter" => RuleCondition::Parameter {
|
||||
parameter: parameters.first().cloned().unwrap_or_default(),
|
||||
operator: RuleOperator::Ge,
|
||||
expression: "10 mm".into(),
|
||||
},
|
||||
"Suppression" => RuleCondition::Suppression { feature: features.first().map(|f| f.0).unwrap_or(0), value: true },
|
||||
"All" => RuleCondition::All { conditions: vec![RuleCondition::Always] },
|
||||
"Any" => RuleCondition::Any { conditions: vec![RuleCondition::Always] },
|
||||
"Not" => RuleCondition::Not { condition: Box::new(RuleCondition::Always) },
|
||||
_ => RuleCondition::Always,
|
||||
};
|
||||
}
|
||||
match condition {
|
||||
RuleCondition::Parameter { parameter, operator, expression } => {
|
||||
ui.horizontal(|ui| {
|
||||
choose(ui, "condition_parameter", parameter, parameters);
|
||||
egui::ComboBox::from_id_salt("condition_operator")
|
||||
.width(45.0)
|
||||
.selected_text(match operator {
|
||||
RuleOperator::Eq => "=",
|
||||
RuleOperator::Ne => "≠",
|
||||
RuleOperator::Lt => "<",
|
||||
RuleOperator::Le => "≤",
|
||||
RuleOperator::Gt => ">",
|
||||
RuleOperator::Ge => "≥",
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
for (label, op) in [
|
||||
("=", RuleOperator::Eq),
|
||||
("≠", RuleOperator::Ne),
|
||||
("<", RuleOperator::Lt),
|
||||
("≤", RuleOperator::Le),
|
||||
(">", RuleOperator::Gt),
|
||||
("≥", RuleOperator::Ge),
|
||||
] {
|
||||
ui.selectable_value(operator, op, label);
|
||||
}
|
||||
});
|
||||
ui.add(egui::TextEdit::singleline(expression).desired_width(110.0));
|
||||
});
|
||||
}
|
||||
RuleCondition::Suppression { feature, value } => {
|
||||
ui.horizontal(|ui| {
|
||||
feature_choice(ui, "condition_feature", feature, features);
|
||||
ui.checkbox(value, "Suppressed");
|
||||
});
|
||||
}
|
||||
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
|
||||
let mut remove = None;
|
||||
for (index, child) in conditions.iter_mut().enumerate() {
|
||||
ui.push_id(index, |ui| {
|
||||
ui.indent("condition_group", |ui| {
|
||||
condition_editor(ui, child, parameters, features, depth + 1);
|
||||
if ui.small_button("Remove condition").clicked() {
|
||||
remove = Some(index);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if let Some(index) = remove {
|
||||
conditions.remove(index);
|
||||
}
|
||||
if ui.add_enabled(conditions.len() < 32, egui::Button::new("Add condition").small()).clicked() {
|
||||
conditions.push(RuleCondition::Always);
|
||||
}
|
||||
}
|
||||
RuleCondition::Not { condition } => {
|
||||
ui.indent("negated_condition", |ui| {
|
||||
condition_editor(ui, condition, parameters, features, depth + 1);
|
||||
});
|
||||
}
|
||||
RuleCondition::Always => {}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,9 @@ pub enum Target {
|
||||
Component {
|
||||
id: u64,
|
||||
},
|
||||
Occurrence {
|
||||
path: Vec<u64>,
|
||||
},
|
||||
/// A browser group of items.
|
||||
Group {
|
||||
id: u64,
|
||||
@@ -227,6 +230,7 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec<String> {
|
||||
Target::Viewport => touched_bodies(app),
|
||||
Target::Body { name } => vec![name.clone()],
|
||||
Target::Component { id } => component_bodies(app, *id),
|
||||
Target::Occurrence { path } => app.session.occurrence_bodies(path, true).unwrap_or_default(),
|
||||
Target::Group { id } => crate::browser::group_bodies(app, *id),
|
||||
Target::Sketch { .. }
|
||||
| Target::Folder { .. }
|
||||
@@ -244,14 +248,7 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec<String> {
|
||||
/// Bodies in a component and its subcomponents.
|
||||
pub fn component_bodies(app: &SolveApp, id: u64) -> Vec<String> {
|
||||
let doc = &app.session.doc;
|
||||
app.session
|
||||
.model
|
||||
.state()
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| doc.component_within(doc.body_component(&b.name, b.feature), id))
|
||||
.map(|b| b.name.clone())
|
||||
.collect()
|
||||
app.session.model.state().bodies.iter().filter(|b| doc.component_within(doc.model_body_component(b), id)).map(|b| b.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// The eight radial commands, clockwise from the top.
|
||||
@@ -292,6 +289,7 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
|
||||
Target::Body { name } => body_items(app, std::slice::from_ref(name), true),
|
||||
Target::Sketch { id } => sketch_items(app, *id),
|
||||
Target::Component { id } => component_items(app, *id),
|
||||
Target::Occurrence { path } => occurrence_items(app, path),
|
||||
Target::Group { id } => crate::browser::group_items(app, *id),
|
||||
Target::Folder { component, folder } => crate::browser::folder_items(app, *component, folder),
|
||||
Target::Canvas { id } => crate::browser::canvas_items(app, *id),
|
||||
@@ -366,7 +364,7 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
|
||||
v.push(act("ui.editFeature", "Edit Feature", "").with(json!({ "feature": feature })).on(feature.is_some()));
|
||||
v.push(act("ui.editSketch", "Edit Profile Sketch", "sketch").with(json!({ "sketch": sketch })).on(sketch.is_some()));
|
||||
v.push(Item::sep());
|
||||
v.push(act("ui.appearance", "Appearance", "").key("A").on(false));
|
||||
v.push(act("ui.appearance", "Appearance", "appearance").key("A"));
|
||||
v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": [face] })));
|
||||
v.push(Item::sep());
|
||||
v.push(act("ui.delete", "Delete", "delete").key("Del"));
|
||||
@@ -437,7 +435,7 @@ fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec<Item> {
|
||||
cmd(app, "component.from_bodies", "Create Components from Bodies").with(json!({ "bodies": names })),
|
||||
Item::sep(),
|
||||
act("ui.material", "Physical Material", "").with(json!({ "bodies": names })),
|
||||
act("ui.appearance", "Appearance", "").key("A").on(false),
|
||||
act("ui.appearance", "Appearance", "appearance").key("A").with(json!({ "bodies": names })),
|
||||
act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })),
|
||||
Item::sep(),
|
||||
act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": names })),
|
||||
@@ -504,6 +502,54 @@ fn joint_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
]
|
||||
}
|
||||
|
||||
fn occurrence_items(app: &SolveApp, path: &[u64]) -> Vec<Item> {
|
||||
let Ok(placement) = app.session.occurrence_placement(path) else { return Vec::new() };
|
||||
let mut entries = component_items(app, placement.component);
|
||||
if app.session.occurrence_scope_count(path).is_ok_and(|n| n > 1) {
|
||||
entries.insert(0, Item::heading("Shared child: edits affect all copies of its parent"));
|
||||
}
|
||||
let bodies = app.session.occurrence_bodies(path, true).unwrap_or_default();
|
||||
for item in &mut entries {
|
||||
match item.id.as_str() {
|
||||
"ui.activate" => {
|
||||
item.params = Some(json!({"component": placement.component, "occurrence": placement.occurrence, "path": path}));
|
||||
item.enabled = app.session.active_occurrence_path.as_deref() != Some(path);
|
||||
}
|
||||
"occurrence.ground" => {
|
||||
item.label = if placement.grounded { "Unground" } else { "Ground" }.into();
|
||||
item.params = Some(json!({"occurrence": placement.occurrence, "grounded": !placement.grounded}));
|
||||
}
|
||||
"ui.moveOccurrence" => {
|
||||
item.params = Some(json!({"occurrence": placement.occurrence, "path": path}));
|
||||
item.enabled = !placement.grounded;
|
||||
}
|
||||
"ui.properties" => item.params = Some(json!({"occurrence": placement.occurrence, "path": path})),
|
||||
"occurrence.copy" => item.params = Some(json!({"occurrence": placement.occurrence, "translate": [10,10,0]})),
|
||||
"component.create" => item.params = Some(json!({"parent": placement.component, "parent_path": path})),
|
||||
"component.paste_new" => {
|
||||
item.label = "Paste New (Own Features)".into();
|
||||
item.params = Some(json!({"component":placement.component,"occurrence":placement.occurrence,"path":path,"translate":[10,10,0]}));
|
||||
}
|
||||
"ui.rename" => item.label = "Rename Component".into(),
|
||||
"ui.appearance" => item.label = "Component Appearance".into(),
|
||||
"ui.material" => item.label = "Component Physical Material".into(),
|
||||
"ui.delete" => item.params = Some(json!({"occurrences": [placement.occurrence]})),
|
||||
"ui.hide" | "ui.show" => {
|
||||
item.id = "browser.visibility".into();
|
||||
item.params = Some(json!({"visible": app.session.occurrence_hidden(path), "paths": [path]}));
|
||||
item.enabled = true;
|
||||
}
|
||||
"ui.isolate" => {
|
||||
item.id = "ui.isolateOccurrence".into();
|
||||
item.params = Some(json!({"path": path}));
|
||||
}
|
||||
"ui.export" | "ui.saveMesh" => item.params = Some(json!({"bodies": bodies})),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn component_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
let doc = &app.session.doc;
|
||||
let occ = doc.occurrence_of(id);
|
||||
@@ -530,8 +576,13 @@ fn component_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
}
|
||||
v.push(Item::sep());
|
||||
v.push(act("ui.material", "Physical Material", "").with(json!({ "bodies": names })).on(!bodies.is_empty()));
|
||||
v.push(act("ui.appearance", "Appearance", "").key("A").on(false));
|
||||
v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })).on(!bodies.is_empty()));
|
||||
v.push(
|
||||
act("ui.appearance", "Appearance", "appearance")
|
||||
.key("A")
|
||||
.with(if id == 0 { json!({ "bodies": names }) } else { json!({"component": id}) })
|
||||
.on(id != 0 || !bodies.is_empty()),
|
||||
);
|
||||
v.push(act("ui.properties", "Properties", "measure").with(json!({ "component": id })));
|
||||
v.push(Item::sep());
|
||||
v.push(act("ui.export", "Export…", "export").with(json!({ "bodies": names })).on(!bodies.is_empty()));
|
||||
v.push(act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": names })).on(!bodies.is_empty()));
|
||||
@@ -594,6 +645,9 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
|
||||
if !curves.is_empty() {
|
||||
v.push(cmd(app, "sketch.construction", "Normal / Construction").with(json!({ "curves": curves })));
|
||||
}
|
||||
if curves.len() == 1 && ss.sketch.freeform_offsets.iter().any(|offset| offset.output == curves[0]) {
|
||||
v.push(cmd(app, "sketch.offset.break", "Break Offset Association").with(json!({"curve":curves[0]})));
|
||||
}
|
||||
v.push(act("ui.fix", "Fix/Unfix", "c_fix").with(json!({ "entities": ents })));
|
||||
v.push(act("ui.delete", "Delete", "delete").key("Del"));
|
||||
v.push(cmd(app, "sketch.dimension", "Sketch Dimension").with(json!({ "entities": ents })).on(ents.len() <= 2));
|
||||
@@ -676,7 +730,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
}
|
||||
"ui.show" => app.session.visibility.hidden_bodies.retain(|b| !bodies.contains(b)),
|
||||
"ui.showAll" => {
|
||||
app.session.visibility.hidden_bodies.clear();
|
||||
let _ = app.run("browser.visibility", json!({"folder":"bodies", "visible":true}));
|
||||
app.session.visibility.hidden_sketches.clear();
|
||||
}
|
||||
"ui.isolate" => {
|
||||
@@ -716,10 +770,26 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
};
|
||||
start_rename(app, what, at);
|
||||
}
|
||||
"ui.properties" => match app.session.execute("inspect.measure", &json!({ "bodies": bodies })) {
|
||||
Ok(v) => app.menu.props = Some((if bodies.len() == 1 { bodies.join("") } else { format!("{} bodies", bodies.len()) }, v)),
|
||||
Err(e) => app.set_status(e.to_string(), true),
|
||||
},
|
||||
"ui.properties" => {
|
||||
let command = if p.get("occurrence").is_some() {
|
||||
"occurrence.properties"
|
||||
} else if p.get("component").is_some() {
|
||||
"component.properties"
|
||||
} else {
|
||||
"inspect.measure"
|
||||
};
|
||||
let params = if command == "inspect.measure" { json!({ "bodies": bodies }) } else { p.clone() };
|
||||
match app.session.execute(command, ¶ms) {
|
||||
Ok(v) => {
|
||||
let title = v["name"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| if bodies.len() == 1 { bodies.join("") } else { format!("{} bodies", bodies.len()) });
|
||||
app.menu.props = Some((title, v));
|
||||
}
|
||||
Err(e) => app.set_status(e.to_string(), true),
|
||||
}
|
||||
}
|
||||
"ui.export" => {
|
||||
let name = bodies.first().cloned().unwrap_or_else(|| app.session.doc.name.clone());
|
||||
if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&format!("{name}.step"), &["step", "stp", "igs", "stl", "3mf", "obj"]))
|
||||
@@ -783,15 +853,27 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
}
|
||||
}
|
||||
"ui.activate" => {
|
||||
if let Some(id) = id_of(&p, "component") {
|
||||
let _ = app.run("component.activate", json!({ "component": id }));
|
||||
if id_of(&p, "component").is_some() {
|
||||
let _ = app.run("component.activate", p);
|
||||
}
|
||||
}
|
||||
"ui.moveOccurrence" => {
|
||||
if let Some(o) = id_of(&p, "occurrence") {
|
||||
app.tree.occurrence_move = Some(crate::browser::OccMove::new(o));
|
||||
let mut movement = crate::browser::OccMove::new(o);
|
||||
movement.had_pending = app.session.pending_moves.contains_key(&o);
|
||||
movement.path = p.get("path").and_then(Value::as_array).map(|p| p.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
movement.baseline = app
|
||||
.session
|
||||
.pending_moves
|
||||
.get(&o)
|
||||
.copied()
|
||||
.or_else(|| app.session.doc.occurrences.iter().find(|occ| occ.id == o).map(|occ| occ.transform));
|
||||
app.tree.occurrence_move = Some(movement);
|
||||
}
|
||||
}
|
||||
"ui.isolateOccurrence" => {
|
||||
let _ = app.run("browser.visibility", json!({"visible": true, "paths": [p["path"]], "isolate": true}));
|
||||
}
|
||||
"ui.canvasVisible" => drop(app.run("canvas.edit", p)),
|
||||
"ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.shortcutPin" => crate::toolbar_custom::run(app, &item.id, &p),
|
||||
"ui.workspace" => {}
|
||||
@@ -847,6 +929,26 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
app.edit_feature(f);
|
||||
}
|
||||
}
|
||||
"ui.appearance" => {
|
||||
if item.params.is_some() {
|
||||
let items: Vec<Value> = bodies.iter().map(|b| json!({"type": "body", "name": b})).collect();
|
||||
let _ = app.run("select.set", json!({ "items": items }));
|
||||
}
|
||||
app.start("appearance.assign");
|
||||
if let Some(name) = id_of(&p, "component").and_then(|id| app.session.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()))
|
||||
&& let Some(d) = app.dialog.as_mut()
|
||||
&& let crate::dialogs::Kind::Appearance(k) = &mut d.kind
|
||||
{
|
||||
if let Some(id) = id_of(&p, "component")
|
||||
&& let Some(look) = app.session.doc.appearances.components.get(&id)
|
||||
{
|
||||
k.color = look.color;
|
||||
k.opacity = look.opacity;
|
||||
k.look = Some(look.name.clone());
|
||||
}
|
||||
k.component = Some(name);
|
||||
}
|
||||
}
|
||||
"ui.material" => {
|
||||
let items: Vec<Value> = bodies.iter().map(|b| json!({"type": "body", "name": b})).collect();
|
||||
let _ = app.run("select.set", json!({ "items": items }));
|
||||
@@ -913,6 +1015,12 @@ pub fn delete(app: &mut SolveApp) {
|
||||
/// axes, components' bodies, canvases). When some are shown and some hidden, all are hidden.
|
||||
pub fn toggle_visibility(app: &mut SolveApp) {
|
||||
use solvecraft_engine::doc::FeatureKind;
|
||||
let paths: Vec<Vec<u64>> =
|
||||
app.session.selection.iter().filter_map(|s| if let Sel::Occurrence { path } = s { Some(path.clone()) } else { None }).collect();
|
||||
if !paths.is_empty() {
|
||||
let visible = !paths.iter().any(|p| !app.session.occurrence_hidden(p));
|
||||
let _ = app.run("browser.visibility", json!({"visible": visible, "paths": paths}));
|
||||
}
|
||||
let mut bodies: Vec<String> = touched_bodies(app);
|
||||
for c in app.tree.picked_components.clone() {
|
||||
bodies.extend(component_bodies(app, c));
|
||||
@@ -959,6 +1067,10 @@ pub fn rename_selection(app: &mut SolveApp, at: Pos2) -> bool {
|
||||
let what = match (app.session.selection.as_slice(), app.tree.picked_components.as_slice(), app.tree.picked_canvases.as_slice()) {
|
||||
([Sel::Body { name }], [], []) => RenameWhat::Body(name.clone()),
|
||||
([Sel::Feature { id }], [], []) => RenameWhat::Feature(*id),
|
||||
([Sel::Occurrence { path }], [], []) => match app.session.occurrence_placement(path) {
|
||||
Ok(p) => RenameWhat::Component(p.component),
|
||||
Err(_) => return false,
|
||||
},
|
||||
([Sel::Plane { name }], [], []) => match app.session.doc.find_feature(name) {
|
||||
Some(f) => RenameWhat::Feature(f.id),
|
||||
None => return false,
|
||||
@@ -1389,14 +1501,9 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option<Item> {
|
||||
return None;
|
||||
}
|
||||
if item.is_heading() {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover());
|
||||
ui.painter().text(
|
||||
pos2(r.left() + 8.0, r.center().y + 2.0),
|
||||
Align2::LEFT_CENTER,
|
||||
item.label.to_uppercase(),
|
||||
FontId::proportional(10.5),
|
||||
t.text_dim,
|
||||
);
|
||||
let text = ui.painter().layout(item.label.to_uppercase(), FontId::proportional(10.5), t.text_dim, w - 16.0);
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, (text.size().y + 8.0).max(20.0)), Sense::hover());
|
||||
ui.painter().galley(pos2(r.left() + 8.0, r.top() + 4.0), text, t.text_dim);
|
||||
return None;
|
||||
}
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), if item.enabled { Sense::click() } else { Sense::hover() });
|
||||
@@ -1502,7 +1609,8 @@ fn show_props(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.label("Area");
|
||||
ui.label(crate::prefs::show_mm(app, f(total, "area_mm2"), 2));
|
||||
ui.end_row();
|
||||
if let [b] = bodies.as_slice() {
|
||||
let geometry = if total.get("center_of_mass").is_some() { Some(total) } else { bodies.first().filter(|_| bodies.len() == 1) };
|
||||
if let Some(b) = geometry {
|
||||
if let Some(c) = b["center_of_mass"].as_array() {
|
||||
let c: Vec<String> = c.iter().map(|x| crate::prefs::show_mm_bare(app, x.as_f64().unwrap_or(f64::NAN))).collect();
|
||||
ui.label("Center of mass");
|
||||
|
||||
@@ -304,6 +304,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
ok(json!(list.into_iter().map(|(n, p)| json!({"name": n, "x": p.x, "y": p.y})).collect::<Vec<_>>()))
|
||||
}
|
||||
"ui.at" => {
|
||||
if let Some(path) = p.get("occurrence").and_then(Value::as_array) {
|
||||
let ids: Option<Vec<u64>> = path.iter().map(Value::as_u64).collect();
|
||||
let Some(ids) = ids else { return err("occurrence path must contain integer ids") };
|
||||
let key = ids.iter().map(u64::to_string).collect::<Vec<_>>().join("/");
|
||||
let control = s("control").unwrap_or("row");
|
||||
return match crate::scenario::handle_at(&format!("{control}:occurrence:{key}")) {
|
||||
Some(q) => ok(json!([q.x, q.y])),
|
||||
None => err("occurrence row or control is not shown"),
|
||||
};
|
||||
}
|
||||
// A scenario point: {world: [x,y,z]} | {sketch: [x,y]} | {plane: "XY"} (the middle of an
|
||||
// origin plane's square) → screen point.
|
||||
if let Some(h) = p.get("handle").and_then(Value::as_str) {
|
||||
@@ -466,6 +476,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
}
|
||||
"ui.browser" => ok(json!({
|
||||
"occurrences": crate::browser::drawn_occurrences().iter().map(|(path, label, r, open)| json!({"path": path, "label": label, "y": r.center().y, "open": open})).collect::<Vec<_>>(),
|
||||
"rows": crate::browser::drawn_rows().iter().map(|(label, r, _, open)| json!({"label": label, "y": r.center().y, "open": open})).collect::<Vec<_>>(),
|
||||
"selection": app.session.selection,
|
||||
"picked_components": app.tree.picked_components,
|
||||
|
||||
@@ -28,6 +28,18 @@ fn is_empty(p: &Value) -> bool {
|
||||
["items", "occurrences", "components", "canvases"].iter().all(|k| p.get(*k).and_then(Value::as_array).is_none_or(|a| a.is_empty()))
|
||||
}
|
||||
|
||||
fn shared_occurrences(app: &SolveApp, params: &Value) -> bool {
|
||||
let explicit = params.get("occurrences").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64);
|
||||
let selected = params
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|item| item["type"] == "occurrence")
|
||||
.filter_map(|item| item["path"].as_array()?.last()?.as_u64());
|
||||
explicit.chain(selected).any(|id| app.session.occurrence_id_scope_count(id).is_ok_and(|n| n > 1))
|
||||
}
|
||||
|
||||
/// Delete what is selected (the Delete key). A selected sketch dimension or constraint glyph
|
||||
/// goes first, as before.
|
||||
pub fn delete_selection(app: &mut SolveApp) {
|
||||
@@ -56,7 +68,8 @@ pub fn request(app: &mut SolveApp, params: Value) {
|
||||
params.get("items").and_then(Value::as_array).map_or(0, |a| a.iter().filter(|i| i["type"] == "feature" || i["type"] == "plane").count());
|
||||
let extra = report["deleted"].as_array().map_or(0, Vec::len) > asked;
|
||||
let fails = report["would_fail"].as_array().is_some_and(|a| !a.is_empty());
|
||||
if extra || fails {
|
||||
let shared = shared_occurrences(app, ¶ms);
|
||||
if extra || fails || shared {
|
||||
app.menu.confirm = Some(Pending { params, report });
|
||||
} else {
|
||||
run(app, ¶ms);
|
||||
@@ -101,6 +114,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -60.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_max_width(360.0);
|
||||
if shared_occurrences(app, &p.params) {
|
||||
ui.label("This child is shared. Deleting it removes it from every copy of its parent.");
|
||||
}
|
||||
if !deleted.is_empty() {
|
||||
ui.label("These features will be deleted:");
|
||||
for d in deleted.iter().take(20) {
|
||||
|
||||
+323
-66
@@ -40,6 +40,10 @@ pub enum Kind {
|
||||
distance2: String,
|
||||
/// Taper angle (empty or zero: none).
|
||||
taper: String,
|
||||
/// An explicitly authored zero taper is still a model parameter.
|
||||
taper_present: bool,
|
||||
/// Optional distance beyond the selected end object.
|
||||
to_offset: String,
|
||||
/// Start offset from the profile plane (empty: start on the profile plane).
|
||||
start: String,
|
||||
/// Extent type All (through everything) instead of a distance.
|
||||
@@ -48,6 +52,8 @@ pub enum Kind {
|
||||
Revolve {
|
||||
angle: String,
|
||||
operation: usize,
|
||||
/// An arbitrary stored line until the user deliberately picks a replacement.
|
||||
axis: Option<Value>,
|
||||
},
|
||||
Fillet {
|
||||
radius: String,
|
||||
@@ -262,6 +268,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>)>,
|
||||
/// A user pick or clear makes the profile selector explicit, even if all are reselected.
|
||||
pub profile_selection_changed: bool,
|
||||
/// Give the on-canvas value box the keyboard (and select its text) on the next frame.
|
||||
pub focus: bool,
|
||||
/// Folded to its header (the "−" glyph).
|
||||
@@ -282,13 +290,23 @@ fn fits(a: Accept, s: &Sel) -> bool {
|
||||
Sel::SketchCurve { .. } => a & (AXES | CURVES) != 0,
|
||||
Sel::Vertex { .. } => a & selection::VERTICES != 0,
|
||||
Sel::Feature { .. } => a & selection::FEATURES != 0,
|
||||
Sel::SketchPoint { .. } | Sel::SketchConstraint { .. } => false,
|
||||
Sel::SketchPoint { .. } | Sel::SketchConstraint { .. } | Sel::Occurrence { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
pub(crate) fn new(kind: Kind, inputs: Vec<SelInput>) -> Dialog {
|
||||
Dialog { kind, inputs, active: 0, error: None, editing: None, focus: true, collapsed: false, extra: serde_json::Map::new() }
|
||||
Dialog {
|
||||
kind,
|
||||
inputs,
|
||||
active: 0,
|
||||
error: None,
|
||||
editing: None,
|
||||
profile_selection_changed: false,
|
||||
focus: true,
|
||||
collapsed: false,
|
||||
extra: serde_json::Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_command(app: &SolveApp, id: &str) -> Option<Dialog> {
|
||||
@@ -304,13 +322,15 @@ impl Dialog {
|
||||
auto_op: true,
|
||||
distance2: "10 mm".into(),
|
||||
taper: "0 deg".into(),
|
||||
taper_present: false,
|
||||
to_offset: String::new(),
|
||||
start: String::new(),
|
||||
all: false,
|
||||
},
|
||||
vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)],
|
||||
),
|
||||
"solid.revolve" => Dialog::new(
|
||||
Kind::Revolve { angle: "360 deg".into(), operation: 0 },
|
||||
Kind::Revolve { angle: "360 deg".into(), operation: 0, axis: None },
|
||||
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Axis", AXES, false)],
|
||||
),
|
||||
"solid.fillet" => Dialog::new(fillet_kind("2 mm", false), vec![SelInput::new("Edges", EDGES | FACES, true)]),
|
||||
@@ -332,7 +352,10 @@ impl Dialog {
|
||||
Kind::Rib { web: id == "solid.web", thickness: "2 mm".into(), depth: String::new(), flip: false },
|
||||
vec![SelInput::new("Curves", CURVES, true)],
|
||||
),
|
||||
"solid.emboss" => Dialog::new(Kind::Emboss { depth: "1 mm".into(), deboss: false }, vec![SelInput::new("Profiles", PROFILES, true)]),
|
||||
"solid.emboss" => Dialog::new(
|
||||
Kind::Emboss { depth: "1 mm".into(), deboss: false },
|
||||
vec![SelInput::new("Profiles", PROFILES, true), SelInput::new("Target Face (optional)", FACES, false)],
|
||||
),
|
||||
"solid.replace_face" => Dialog::new(
|
||||
Kind::ReplaceFace,
|
||||
vec![SelInput::new("Faces", PLANAR_FACES, true), SelInput::new("Target", PLANES | PLANAR_FACES, false)],
|
||||
@@ -567,6 +590,7 @@ 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 { to_offset, .. } if to_object_on(&self.inputs) => ("To Offset", ValueKind::Length, to_offset),
|
||||
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),
|
||||
@@ -633,6 +657,9 @@ impl Dialog {
|
||||
|
||||
/// A pick in the viewport (already accepted by the active input).
|
||||
pub fn pick(&mut self, s: &Session, sel: Sel) {
|
||||
if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) {
|
||||
self.profile_selection_changed = true;
|
||||
}
|
||||
let measure = matches!(self.kind, Kind::Measure { .. });
|
||||
let sel = if measure { crate::viewport::measurement_selection(s, &sel) } else { sel };
|
||||
let chain = matches!(self.kind, Kind::Fillet { chain: true, .. });
|
||||
@@ -673,6 +700,9 @@ impl Dialog {
|
||||
|
||||
/// Box selection result: replace (or add to) the active input.
|
||||
pub fn take_box(&mut self, s: &Session, sels: Vec<Sel>, add: bool) {
|
||||
if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) {
|
||||
self.profile_selection_changed = true;
|
||||
}
|
||||
let measure = matches!(self.kind, Kind::Measure { .. });
|
||||
let Some(inp) = self.inputs.get_mut(self.active) else { return };
|
||||
if !add {
|
||||
@@ -1024,9 +1054,10 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
let active = i == d.active;
|
||||
// A chip: "↖ Select" (outlined) while empty, "N selected" (filled) once picked.
|
||||
let filled = !inp.items.is_empty();
|
||||
let custom_axis = i == 1 && inp.items.is_empty() && matches!(d.kind, Kind::Revolve { axis: Some(_), .. });
|
||||
let filled = !inp.items.is_empty() || custom_axis;
|
||||
let text = if filled {
|
||||
RichText::new(format!("↖ {} selected", inp.items.len())).color(Color32::WHITE)
|
||||
RichText::new(if custom_axis { "↖ Custom Axis".into() } else { format!("↖ {} selected", inp.items.len()) }).color(Color32::WHITE)
|
||||
} else {
|
||||
RichText::new("↖ Select").color(t.text)
|
||||
};
|
||||
@@ -1039,7 +1070,20 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
|
||||
.sense(egui::Sense::CLICK);
|
||||
let chip = ui.add(b);
|
||||
publish_row(&chip);
|
||||
if chip.on_hover_text(format!("{} (click to pick into this input)", hint(inp))).clicked() {
|
||||
let tooltip = if custom_axis {
|
||||
if let Kind::Revolve { axis: Some(axis), .. } = &d.kind {
|
||||
format!(
|
||||
"Custom axis\nOrigin: {}\nDirection: {}\nClick to select a replacement axis",
|
||||
axis.get("origin").map(ToString::to_string).unwrap_or_default(),
|
||||
axis.get("dir").map(ToString::to_string).unwrap_or_default()
|
||||
)
|
||||
} else {
|
||||
hint(inp).to_string()
|
||||
}
|
||||
} else {
|
||||
format!("{} (click to pick into this input)", hint(inp))
|
||||
};
|
||||
if chip.on_hover_text(tooltip).clicked() {
|
||||
activate = Some(i);
|
||||
}
|
||||
if !inp.items.is_empty() && ui.small_button("×").on_hover_text("Clear the selection").clicked() {
|
||||
@@ -1055,6 +1099,9 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
|
||||
&& let Some(inp) = d.inputs.get_mut(i)
|
||||
{
|
||||
inp.items.clear();
|
||||
if i == 0 {
|
||||
d.profile_selection_changed = true;
|
||||
}
|
||||
d.active = i;
|
||||
}
|
||||
}
|
||||
@@ -1226,6 +1273,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
.stroke(Stroke::new(1.0, t.dialog_border))
|
||||
.corner_radius(egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 })
|
||||
.shadow(egui::Shadow { offset: [-2, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(40) });
|
||||
// The area includes the border as well as the inner margins; reserve all of it so a
|
||||
// horizontally scrolling table cannot push a wide dialog two pixels past its limit.
|
||||
let horizontal_frame = frame.inner_margin.leftf() + frame.inner_margin.rightf() + 2.0 * frame.stroke.width;
|
||||
// OK is offered once the inputs make a feature.
|
||||
let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. });
|
||||
let valid = !has_ok || !d.previews() || (apply_commands(app, &d).is_ok() && app.preview.error.is_none());
|
||||
@@ -1245,7 +1295,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.spacing_mut().text_edit_width = FIELD_W;
|
||||
// Long texts (errors, notes) wrap instead of widening the dialog.
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
let margin = 2.0 * ui.style().spacing.window_margin.leftf();
|
||||
let margin = horizontal_frame;
|
||||
let min_w = if wide { 480.0 } else { DIALOG_MIN_W } - margin;
|
||||
ui.set_min_width(min_w);
|
||||
ui.set_max_width(if wide { DIALOG_WIDE_MAX_W } else { DIALOG_MAX_W } - margin);
|
||||
@@ -1268,22 +1318,58 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
// Configuration tables own their column widths and scrolling; the two-column form
|
||||
// grid would squeeze their row names and action buttons into a label-sized cell.
|
||||
if let Kind::Motion(k @ crate::dialogs_motion::Mo::Configurations { .. }) = &mut d.kind {
|
||||
enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs);
|
||||
}
|
||||
egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
|
||||
if matches!(&d.kind, Kind::Motion(crate::dialogs_motion::Mo::Configurations { .. })) {
|
||||
return;
|
||||
}
|
||||
placement_row(&mut d, ui);
|
||||
input_rows(&mut d, ui);
|
||||
match &mut d.kind {
|
||||
Kind::Sketch => {}
|
||||
Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, start, all } => {
|
||||
Kind::Sketch => {
|
||||
if d.error.is_some()
|
||||
&& let Some(selection @ Sel::Face { .. }) = d.inputs.first().and_then(|i| i.items.first())
|
||||
{
|
||||
row_label(ui, "Attachment");
|
||||
ui.label("This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.");
|
||||
ui.end_row();
|
||||
row_label(ui, "");
|
||||
if ui.button("Use fixed plane").clicked()
|
||||
&& let Some(mut plane) = crate::sketch_attachment_ui::face_plane(app, selection)
|
||||
{
|
||||
plane["fixed"] = json!(true);
|
||||
match app.run("sketch.create", json!({"plane":plane})) {
|
||||
Ok(_) => {
|
||||
app.pre_sketch_cam = Some(app.cam);
|
||||
look_at_sketch(app);
|
||||
cancel = true;
|
||||
}
|
||||
Err(error) => d.error = Some(error),
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, taper_present, to_offset, start, all } => {
|
||||
row_label(ui, "Start");
|
||||
let mut st = usize::from(!start.is_empty());
|
||||
combo(ui, "ex_start", &["Profile Plane", "Offset"], &mut st);
|
||||
if st == 1 && start.is_empty() {
|
||||
*start = "5 mm".into();
|
||||
} else if st == 0 {
|
||||
start.clear();
|
||||
let old = if object_on(&d.inputs, FROM_OBJECT) { 2 } else { usize::from(!start.is_empty()) };
|
||||
let mut st = old;
|
||||
combo(ui, "ex_start", &["Profile Plane", "Offset", "From Object"], &mut st);
|
||||
if st != old {
|
||||
set_object(&mut d.inputs, &mut d.active, FROM_OBJECT, st == 2);
|
||||
if st == 1 && start.is_empty() {
|
||||
*start = "5 mm".into();
|
||||
}
|
||||
if st == 0 {
|
||||
start.clear();
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
if !start.is_empty() {
|
||||
if st != 0 {
|
||||
row_label(ui, "Offset");
|
||||
enter |= field(ui, start);
|
||||
ui.end_row();
|
||||
@@ -1293,8 +1379,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let order = [0usize, TWO_SIDES, 2];
|
||||
let mut pos = order.iter().position(|o| o == direction).unwrap_or(0);
|
||||
combo(ui, "ex_dir", &["One Side", "Two Sides", "Symmetric"], &mut pos);
|
||||
*direction = order.get(pos).copied().unwrap_or(0);
|
||||
if pos != order.iter().position(|o| o == direction).unwrap_or(0) {
|
||||
*direction = order.get(pos).copied().unwrap_or(0);
|
||||
}
|
||||
ui.end_row();
|
||||
if *direction < 2 {
|
||||
row_label(ui, "Reverse");
|
||||
let mut reverse = *direction == 1;
|
||||
if ui.checkbox(&mut reverse, "Reverse Direction").changed() {
|
||||
*direction = usize::from(reverse);
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(ui, "Extent Type");
|
||||
let cur = if to_object_on(&d.inputs) { 1 } else { usize::from(*all) * 2 };
|
||||
let mut ext = cur;
|
||||
@@ -1312,9 +1408,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
if ext == 1 {
|
||||
row_label(ui, "To Offset");
|
||||
enter |= field(ui, to_offset);
|
||||
ui.end_row();
|
||||
}
|
||||
if *direction < 2 {
|
||||
row_label(ui, "Taper Angle");
|
||||
let before = taper.clone();
|
||||
enter |= field(ui, taper);
|
||||
if *taper != before {
|
||||
*taper_present = !taper.trim().is_empty();
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(ui, "Operation");
|
||||
@@ -1326,7 +1431,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Revolve { angle, operation } => {
|
||||
Kind::Revolve { angle, operation, .. } => {
|
||||
row_label(ui, "Type");
|
||||
let cur = usize::from(to_object_on(&d.inputs));
|
||||
let mut ty = cur;
|
||||
@@ -1530,6 +1635,14 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
combo(ui, "emb_mode", &["Emboss", "Deboss"], &mut i);
|
||||
*deboss = i == 1;
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
"Orthogonal line profiles tangent to one supported cylinder wrap automatically. Pick a wall to choose a target.",
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Align { flip } => {
|
||||
// Where on the picked face or edge to snap: as picked (a face turns to meet
|
||||
@@ -1841,10 +1954,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
// A plane picked for a new sketch starts it right away.
|
||||
if matches!(d.kind, Kind::Sketch)
|
||||
&& d.error.is_none()
|
||||
&& let Some(sel) = d.inputs.first().and_then(|i| i.items.first()).cloned()
|
||||
{
|
||||
start_sketch(app, &sel);
|
||||
cancel = true;
|
||||
match start_sketch(app, &sel) {
|
||||
Ok(()) => cancel = true,
|
||||
Err(error) => d.error = Some(error),
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
match run_dialog(app, &d) {
|
||||
@@ -1862,7 +1978,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
app.dialog = Some(d);
|
||||
} else if let Some((_, marker)) = d.editing {
|
||||
// Editing done: put the timeline marker back where it was.
|
||||
let _ = app.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({})));
|
||||
let _ = roll_marker(app, marker);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1875,12 +1991,30 @@ pub fn cancel(app: &mut SolveApp) {
|
||||
if let Some(d) = app.dialog.take()
|
||||
&& let Some((_, marker)) = d.editing
|
||||
{
|
||||
let _ = app.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({})));
|
||||
let _ = roll_marker(app, marker);
|
||||
}
|
||||
}
|
||||
|
||||
/// Temporary rollback used by an editor is view state. Preserve both history stacks,
|
||||
/// including redo and a history already at the undo limit.
|
||||
pub(crate) fn roll_marker(app: &mut SolveApp, marker: Option<usize>) -> Result<(), String> {
|
||||
let undo = std::mem::take(&mut app.session.undo);
|
||||
let redo = std::mem::take(&mut app.session.redo);
|
||||
let selection = std::mem::take(&mut app.session.selection);
|
||||
let result = app.run("timeline.roll_to", marker.map(|m| json!({"position":m})).unwrap_or_else(|| json!({})));
|
||||
app.session.undo = undo;
|
||||
app.session.redo = redo;
|
||||
app.session.selection = selection;
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
/// The pick input an extent of To Object adds (a face, vertex or point to go to).
|
||||
const TO_OBJECT: &str = "To Object";
|
||||
const FROM_OBJECT: &str = "From Object";
|
||||
|
||||
fn object_on(inputs: &[SelInput], label: &str) -> bool {
|
||||
inputs.iter().any(|i| i.label == label)
|
||||
}
|
||||
|
||||
fn to_object_on(inputs: &[SelInput]) -> bool {
|
||||
inputs.iter().any(|i| i.label == TO_OBJECT)
|
||||
@@ -1888,18 +2022,26 @@ fn to_object_on(inputs: &[SelInput]) -> bool {
|
||||
|
||||
/// Add or remove the To Object input; the active input moves to it when it is added.
|
||||
fn set_to_object(inputs: &mut Vec<SelInput>, active: &mut usize, on: bool) {
|
||||
if on && !to_object_on(inputs) {
|
||||
inputs.push(SelInput::new(TO_OBJECT, FACES | selection::VERTICES, false));
|
||||
set_object(inputs, active, TO_OBJECT, on);
|
||||
}
|
||||
|
||||
fn set_object(inputs: &mut Vec<SelInput>, active: &mut usize, label: &'static str, on: bool) {
|
||||
if on && !object_on(inputs, label) {
|
||||
inputs.push(SelInput::new(label, FACES | selection::VERTICES, false));
|
||||
*active = inputs.len() - 1;
|
||||
} else if !on && to_object_on(inputs) {
|
||||
inputs.retain(|i| i.label != TO_OBJECT);
|
||||
} else if !on && object_on(inputs, label) {
|
||||
inputs.retain(|i| i.label != label);
|
||||
*active = (*active).min(inputs.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// The picked To Object place.
|
||||
fn to_object_point(inputs: &[SelInput]) -> Option<Value> {
|
||||
inputs.iter().find(|i| i.label == TO_OBJECT)?.items.first().and_then(|x| match x {
|
||||
object_point(inputs, TO_OBJECT)
|
||||
}
|
||||
|
||||
pub(crate) fn object_point(inputs: &[SelInput], label: &str) -> Option<Value> {
|
||||
inputs.iter().find(|i| i.label == label)?.items.first().and_then(|x| match x {
|
||||
Sel::Face { point, .. } | Sel::Vertex { point, .. } | Sel::Edge { point, .. } => Some(pt(*point)),
|
||||
_ => None,
|
||||
})
|
||||
@@ -1907,9 +2049,13 @@ fn to_object_point(inputs: &[SelInput]) -> Option<Value> {
|
||||
|
||||
/// A To Object input filled from a stored place (editing).
|
||||
fn with_to_object(d: &mut Dialog, s: &Session, to: Option<Vec3>) {
|
||||
with_object(d, s, TO_OBJECT, to);
|
||||
}
|
||||
|
||||
fn with_object(d: &mut Dialog, s: &Session, label: &'static str, to: Option<Vec3>) {
|
||||
if let Some(p) = to {
|
||||
set_to_object(&mut d.inputs, &mut d.active, true);
|
||||
if let Some(inp) = d.inputs.iter_mut().find(|i| i.label == TO_OBJECT) {
|
||||
set_object(&mut d.inputs, &mut d.active, label, true);
|
||||
if let Some(inp) = d.inputs.iter_mut().find(|i| i.label == label) {
|
||||
inp.items = vec![face_sel(s, p).unwrap_or(Sel::Vertex { body: String::new(), point: p })];
|
||||
}
|
||||
}
|
||||
@@ -2034,25 +2180,25 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne
|
||||
}
|
||||
|
||||
/// Start a sketch on a picked plane or planar face and turn the view to face it.
|
||||
pub fn start_sketch(app: &mut SolveApp, sel: &Sel) {
|
||||
pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> {
|
||||
let plane = match sel {
|
||||
Sel::Plane { name } => json!(name),
|
||||
Sel::Face { point, .. } => json!({"face": pt(*point)}),
|
||||
_ => return,
|
||||
Sel::Face { .. } => crate::sketch_attachment_ui::face_plane(app, sel).ok_or("Selected face placement no longer exists")?,
|
||||
_ => return Err("Select an origin plane, construction plane or planar body face".into()),
|
||||
};
|
||||
let before = app.cam;
|
||||
if app.run("sketch.create", json!({ "plane": plane })).is_ok() {
|
||||
app.pre_sketch_cam = Some(before);
|
||||
look_at_sketch(app);
|
||||
// On a face: look at the picked point, not the plane's origin (a corner of the face),
|
||||
// which left the face filling the view off-centre like a big square.
|
||||
if let Sel::Face { point, .. } = sel
|
||||
&& let Some(mut to) = app.cam_anim.as_ref().map(|a| a.to)
|
||||
{
|
||||
to.target = *point;
|
||||
app.animate_to(to);
|
||||
}
|
||||
app.run("sketch.create", json!({ "plane": plane }))?;
|
||||
app.pre_sketch_cam = Some(before);
|
||||
look_at_sketch(app);
|
||||
// On a face: look at the picked point, not the plane's origin (a corner of the face),
|
||||
// which left the face filling the view off-centre like a big square.
|
||||
if let Sel::Face { point, .. } = sel
|
||||
&& let Some(mut to) = app.cam_anim.as_ref().map(|a| a.to)
|
||||
{
|
||||
to.target = *point;
|
||||
app.animate_to(to);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Animate the camera to look straight at the active sketch plane.
|
||||
@@ -2075,17 +2221,58 @@ fn sels(d: &Dialog, i: usize) -> &[Sel] {
|
||||
}
|
||||
|
||||
fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> {
|
||||
for (cmd, params) in apply_commands(app, d)? {
|
||||
app.run(&cmd, params)?;
|
||||
let commands = apply_commands(app, d)?;
|
||||
if let Some((_, marker)) = d.editing {
|
||||
// Record the actual pre-edit timeline state in the command's undo snapshot.
|
||||
roll_marker(app, marker)?;
|
||||
}
|
||||
for (cmd, params) in commands {
|
||||
if let Err(error) = app.run(&cmd, params) {
|
||||
if let Some((id, _)) = d.editing {
|
||||
let position = app.session.doc.feature_index(id);
|
||||
let _ = roll_marker(app, position);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Placement used to edit a definition in the selected authoring occurrence.
|
||||
pub(crate) fn feature_frame(s: &Session, component: u64) -> solvecraft_engine::doc::Mat {
|
||||
if component == s.active_component { s.active_component_frame() } else { s.placed_document().component_transform(component) }
|
||||
}
|
||||
|
||||
/// The commands OK runs (an edit rebuilds its feature in place); the live preview runs the same.
|
||||
pub fn apply_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, String> {
|
||||
let cmds = dialog_commands(app, d)?;
|
||||
if let Some((id, _)) = d.editing {
|
||||
let (cmd, params) = cmds.into_iter().next().ok_or("nothing to apply")?;
|
||||
let (cmd, mut params) = cmds.into_iter().next().ok_or("nothing to apply")?;
|
||||
if let Some(feature) = app.session.doc.feature(id) {
|
||||
let original_all = match &feature.kind {
|
||||
FeatureKind::Extrude { sketch, profiles: ProfileSel::All, .. }
|
||||
| FeatureKind::Revolve { sketch, profiles: ProfileSel::All, .. }
|
||||
| FeatureKind::Emboss { sketch, profiles: ProfileSel::All, .. } => Some(*sketch),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(sketch) = original_all
|
||||
&& !d.profile_selection_changed
|
||||
&& let Some(ss) = app.session.world_state().sketch(sketch)
|
||||
{
|
||||
let mut selected: Vec<usize> = sels(d, 0)
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Sel::Profile { sketch: id, index } if *id == sketch => Some(*index),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
selected.sort_unstable();
|
||||
selected.dedup();
|
||||
if selected == (0..ss.profiles.len()).collect::<Vec<_>>() && sels(d, 0).len() == selected.len() {
|
||||
params["profiles"] = json!("all");
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(vec![("timeline.redefine".into(), json!({"feature": id, "command": cmd, "params": params}))]);
|
||||
}
|
||||
Ok(cmds)
|
||||
@@ -2100,6 +2287,31 @@ pub fn preview_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value
|
||||
Ok(cmds)
|
||||
}
|
||||
|
||||
/// A wrapped Emboss edit keeps its profile guide in the saved author occurrence.
|
||||
/// An invalid saved context must not draw the guide at a different occurrence.
|
||||
pub(crate) fn emboss_profile_plane(app: &SolveApp, sketch: u64) -> Result<Option<solvecraft_engine::geom::Plane>, String> {
|
||||
let Some(dialog) = app.dialog.as_ref().filter(|d| matches!(d.kind, Kind::Emboss { .. })) else { return Ok(None) };
|
||||
let Some((feature, _)) = dialog.editing else { return Ok(None) };
|
||||
let Some(feature) = app.session.doc.feature(feature) else { return Err("the edited feature is missing".into()) };
|
||||
let FeatureKind::Emboss { sketch: source, face: Some(face), .. } = &feature.kind else { return Ok(None) };
|
||||
if *source != sketch {
|
||||
return Ok(None);
|
||||
}
|
||||
let model = app.session.model.state();
|
||||
let sketch = model.sketch(sketch).ok_or("the Emboss sketch is missing")?;
|
||||
let body = model.body(&face.body).ok_or("the Emboss target body is missing")?;
|
||||
let owner = app.session.doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
|
||||
let plane = sketch.plane;
|
||||
let frame = face.sketch_frame(&app.session.doc, feature.component, owner, body).map_err(|error| error.to_string())?;
|
||||
solvecraft_engine::geom::Plane::new(
|
||||
solvecraft_engine::doc::apply_point(&frame, plane.origin),
|
||||
solvecraft_engine::doc::apply_vector(&frame, plane.x),
|
||||
solvecraft_engine::doc::apply_vector(&frame, plane.y),
|
||||
)
|
||||
.map(Some)
|
||||
.ok_or_else(|| "the Emboss author frame is degenerate".into())
|
||||
}
|
||||
|
||||
/// The commands (id, parameters) a dialog's OK runs.
|
||||
fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, String> {
|
||||
let s = &app.session;
|
||||
@@ -2115,13 +2327,13 @@ 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, distance2, taper, start, all, .. } => {
|
||||
Kind::Extrude { distance, direction, operation, distance2, taper, taper_present, to_offset, start, all, .. } => {
|
||||
need(0, "profiles or a planar face")?;
|
||||
let mut common = json!({"distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||||
let tapered = s.doc.eval(taper, ValueKind::Angle).map(|v| v.abs() > 1e-12).unwrap_or(!taper.trim().is_empty());
|
||||
if *direction == TWO_SIDES {
|
||||
common["distance2"] = json!(distance2);
|
||||
} else if *direction < 2 && tapered {
|
||||
} else if *direction < 2 && (*taper_present || tapered) {
|
||||
common["taper"] = json!(taper);
|
||||
}
|
||||
if !start.trim().is_empty() {
|
||||
@@ -2132,6 +2344,12 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
}
|
||||
if to_object_on(&d.inputs) {
|
||||
common["to"] = to_object_point(&d.inputs).ok_or("select the object to extrude to")?;
|
||||
if !to_offset.trim().is_empty() {
|
||||
common["to_offset"] = json!(to_offset);
|
||||
}
|
||||
}
|
||||
if object_on(&d.inputs, FROM_OBJECT) {
|
||||
common["from"] = object_point(&d.inputs, FROM_OBJECT).ok_or("select the object to extrude from")?;
|
||||
}
|
||||
let with = |extra: Value| -> Value {
|
||||
let mut p = common.clone();
|
||||
@@ -2154,21 +2372,18 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
Kind::Revolve { angle, operation } => {
|
||||
Kind::Revolve { angle, operation, axis: stored_axis } => {
|
||||
need(0, "profiles")?;
|
||||
need(1, "an axis")?;
|
||||
let axis = match sels(d, 1).first() {
|
||||
Some(Sel::Axis { name }) => json!(name),
|
||||
Some(Sel::SketchCurve { id }) => json!(id),
|
||||
_ => return Err("select an axis".into()),
|
||||
_ => stored_axis.clone().ok_or("select an axis")?,
|
||||
};
|
||||
let (sketch, idx) = profiles();
|
||||
let mut p =
|
||||
json!({"sketch": sketch, "profiles": idx, "axis": axis, "angle": angle, "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||||
if to_object_on(&d.inputs) {
|
||||
p["to"] = to_object_point(&d.inputs).ok_or("select the object to turn to")?;
|
||||
// A To Object turn has no second side.
|
||||
return Ok(vec![("solid.revolve".into(), p)]);
|
||||
}
|
||||
("solid.revolve", p)
|
||||
}
|
||||
@@ -2350,7 +2565,12 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
Kind::Emboss { depth, deboss } => {
|
||||
need(0, "profiles")?;
|
||||
let (sketch, idx) = profiles();
|
||||
("solid.emboss", json!({"sketch": sketch, "profiles": idx, "depth": depth, "mode": if *deboss { "deboss" } else { "emboss" }}))
|
||||
let mut p = json!({"sketch": sketch, "profiles": idx, "depth": depth, "mode": if *deboss { "deboss" } else { "emboss" }});
|
||||
if let Some(Sel::Face { body, point, .. }) = sels(d, 1).first() {
|
||||
p["face"] = pt(*point);
|
||||
p["targets"] = json!([body]);
|
||||
}
|
||||
("solid.emboss", p)
|
||||
}
|
||||
Kind::ReplaceFace => {
|
||||
need(0, "faces to move")?;
|
||||
@@ -2772,6 +2992,18 @@ pub(crate) fn face_sel(s: &Session, p0: Vec3) -> Option<Sel> {
|
||||
best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p0.len()).map(|x| x.1)
|
||||
}
|
||||
|
||||
fn emboss_face_sel(s: &Session, component: u64, face: &solvecraft_engine::doc::EmbossFace, persistent: &str) -> Option<Sel> {
|
||||
let source = s.model.state();
|
||||
let body = source.body(&face.body)?;
|
||||
let index = solvecraft_engine::doc::naming::face_names(body).iter().position(|name| name == persistent)?;
|
||||
let owner = s.doc.model_body_component(body);
|
||||
let world_name = s.doc.world_body_name(&source, &face.body, owner, &face.path);
|
||||
let author = solvecraft_engine::doc::EmbossFace::component_frame(&s.doc, component, &face.author_path).ok()?;
|
||||
let point = solvecraft_engine::doc::apply_point(&author, face.at);
|
||||
s.world_state().body(&world_name)?;
|
||||
Some(Sel::Face { body: world_name, index, point })
|
||||
}
|
||||
|
||||
pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
|
||||
match pl {
|
||||
PlaneRef::Origin { name } | PlaneRef::Construction { name } => Some(Sel::Plane { name: name.clone() }),
|
||||
@@ -2829,16 +3061,21 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
auto_op: false,
|
||||
distance2: extent.distance2.clone().unwrap_or_else(|| "10 mm".into()),
|
||||
taper: extent.taper.clone().unwrap_or_else(|| "0 deg".into()),
|
||||
taper_present: extent.taper.is_some(),
|
||||
to_offset: extent.to_offset.clone().unwrap_or_default(),
|
||||
start: extent.start_offset.clone().unwrap_or_default(),
|
||||
all: extent.through_all,
|
||||
};
|
||||
with_to_object(&mut d, s, extent.to);
|
||||
// The dialog has no "flip": a flipped extrude is a negative distance.
|
||||
if let Kind::Extrude { direction, distance, .. } = &mut d.kind
|
||||
&& *direction == 1
|
||||
{
|
||||
*direction = 0;
|
||||
*distance = format!("-({distance})");
|
||||
// Extent references belong to the extrude's authoring frame, including free
|
||||
// points. They are not body-local picks (a reference may be on another body).
|
||||
let frame = feature_frame(s, f.component);
|
||||
for (label, point) in [(TO_OBJECT, extent.to), (FROM_OBJECT, extent.from)] {
|
||||
if let Some(point) = point {
|
||||
set_object(&mut d.inputs, &mut d.active, label, true);
|
||||
if let Some(input) = d.inputs.iter_mut().find(|i| i.label == label) {
|
||||
input.items = vec![Sel::Vertex { body: String::new(), point: solvecraft_engine::doc::apply_point(&frame, point) }];
|
||||
}
|
||||
}
|
||||
}
|
||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||
if let Some(inp) = d.inputs.first_mut() {
|
||||
@@ -2849,22 +3086,37 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets, angle2, symmetric, to } => {
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets, angle2, symmetric, convention, to } => {
|
||||
let mut d = start("solid.revolve")?;
|
||||
d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation) };
|
||||
let frame = feature_frame(s, f.component);
|
||||
let stored_axis = match axis {
|
||||
AxisRef::Line { origin, dir } => Some(json!({
|
||||
"origin": pt(solvecraft_engine::doc::apply_point(&frame, *origin)),
|
||||
"dir": pt(solvecraft_engine::doc::apply_vector(&frame, *dir))
|
||||
})),
|
||||
_ => None,
|
||||
};
|
||||
d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation), axis: stored_axis };
|
||||
d.extra.insert("convention".into(), json!(convention));
|
||||
if let Some(a2) = angle2 {
|
||||
d.extra.insert("angle2".into(), json!(a2));
|
||||
}
|
||||
if *symmetric {
|
||||
d.extra.insert("direction".into(), json!("symmetric"));
|
||||
}
|
||||
with_to_object(&mut d, s, *to);
|
||||
if let Some(point) = to {
|
||||
set_to_object(&mut d.inputs, &mut d.active, true);
|
||||
if let Some(input) = d.inputs.iter_mut().find(|i| i.label == TO_OBJECT) {
|
||||
input.items = vec![Sel::Vertex { body: String::new(), point: solvecraft_engine::doc::apply_point(&frame, *point) }];
|
||||
}
|
||||
}
|
||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||
if let Some(inp) = d.inputs.get_mut(0) {
|
||||
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
|
||||
}
|
||||
let ax = match axis {
|
||||
AxisRef::World { axis } => Some(Sel::Axis { name: axis.to_ascii_uppercase() }),
|
||||
AxisRef::SketchAxis { axis } => Some(Sel::Axis { name: axis.to_ascii_lowercase() }),
|
||||
AxisRef::SketchLine { curve } => Some(Sel::SketchCurve { id: curve.clone() }),
|
||||
_ => None,
|
||||
};
|
||||
@@ -3278,7 +3530,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets } => {
|
||||
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face } => {
|
||||
let mut d = start("solid.emboss")?;
|
||||
d.kind = Kind::Emboss { depth: depth.clone(), deboss: *deboss };
|
||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||
@@ -3288,6 +3540,11 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
if !targets.is_empty() {
|
||||
d.extra.insert("targets".into(), json!(targets));
|
||||
}
|
||||
if let Some(face) = face
|
||||
&& let Some(input) = d.inputs.get_mut(1)
|
||||
{
|
||||
input.items = vec![emboss_face_sel(s, f.component, face, f.face_names.first()?)?];
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Rib { curves, thickness, depth, flip, web, .. } => {
|
||||
|
||||
@@ -19,12 +19,12 @@ pub struct Look {
|
||||
pub name: String,
|
||||
pub category: String,
|
||||
pub color: [u8; 3],
|
||||
pub opacity: f32,
|
||||
pub opacity: f64,
|
||||
}
|
||||
|
||||
/// Built-in looks, used until the engine offers a library (`appearance.library`).
|
||||
fn builtin() -> Vec<Look> {
|
||||
let l = |cat: &str, name: &str, c: [u8; 3], o: f32| Look { name: name.into(), category: cat.into(), color: c, opacity: o };
|
||||
let l = |cat: &str, name: &str, c: [u8; 3], o: f64| Look { name: name.into(), category: cat.into(), color: c, opacity: o };
|
||||
vec![
|
||||
l("Metal", "Aluminum - Satin", [196, 199, 204], 1.0),
|
||||
l("Metal", "Steel - Brushed", [160, 164, 170], 1.0),
|
||||
@@ -63,7 +63,7 @@ pub fn library(app: &mut SolveApp) -> Vec<Look> {
|
||||
name: x.get("name")?.as_str()?.to_string(),
|
||||
category: x.get("category").and_then(Value::as_str).unwrap_or("Library").to_string(),
|
||||
color: parse_color(x.get("color")?)?,
|
||||
opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0) as f32,
|
||||
opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -96,27 +96,64 @@ pub struct Ap {
|
||||
/// Apply to faces instead of bodies.
|
||||
pub faces: bool,
|
||||
pub color: [u8; 3],
|
||||
pub opacity: f32,
|
||||
pub opacity: f64,
|
||||
/// The look picked last (its name), if the colour came from one.
|
||||
pub look: Option<String>,
|
||||
/// Library category shown (0: all).
|
||||
pub category: usize,
|
||||
/// Library names can be searched without changing the selected look.
|
||||
pub search: String,
|
||||
/// A Browser component target, inherited by its bodies unless they have overrides.
|
||||
pub component: Option<String>,
|
||||
pub remove_override: bool,
|
||||
/// The last successful immediate Apply/drop, so OK does not duplicate its undo step.
|
||||
pub applied: Option<Value>,
|
||||
/// What the last drop or Apply did.
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// The dialog for the Appearance command.
|
||||
pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
(id == "appearance.assign").then(|| {
|
||||
let faces = app.session.selection.iter().any(|s| matches!(s, Sel::Face { .. }))
|
||||
&& !app.session.selection.iter().any(|s| matches!(s, Sel::Body { .. }));
|
||||
let state = app.session.world_state();
|
||||
let body_look = |body: &solvecraft_engine::doc::ModelBody| {
|
||||
app.session.doc.model_body_look(body).or_else(|| {
|
||||
(body.body.paint().is_some() || body.imported.is_some() || body.body.color().is_some()).then(|| {
|
||||
let color =
|
||||
app.session.doc.body_color(body).map(|rgb| rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).unwrap_or([196, 199, 204]);
|
||||
solvecraft_engine::doc::appearance::Look::custom(color, app.session.doc.model_body_opacity(body))
|
||||
})
|
||||
})
|
||||
};
|
||||
let existing = app.session.selection.iter().find_map(|sel| match sel {
|
||||
Sel::Body { name } => state.body(name).and_then(body_look),
|
||||
Sel::Face { body, index, .. } => state
|
||||
.body(body)
|
||||
.and_then(|b| app.session.doc.face_colors(b).into_iter().find(|(i, _)| i == index).map(|(_, look)| look).or_else(|| body_look(b))),
|
||||
_ => None,
|
||||
});
|
||||
(
|
||||
Kind::Appearance(Ap { faces: false, color: [196, 199, 204], opacity: 1.0, look: None, category: 0, note: None }),
|
||||
vec![SelInput::new("Objects", BODIES, true)],
|
||||
Kind::Appearance(Ap {
|
||||
faces,
|
||||
color: existing.as_ref().map_or([196, 199, 204], |l| l.color),
|
||||
opacity: existing.as_ref().map_or(1.0, |l| l.opacity),
|
||||
look: existing.map(|l| l.name),
|
||||
category: 0,
|
||||
search: String::new(),
|
||||
component: None,
|
||||
remove_override: false,
|
||||
applied: None,
|
||||
note: None,
|
||||
}),
|
||||
vec![SelInput::new("Objects", if faces { FACES } else { BODIES }, true)],
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The command giving `targets` (bodies or faces) a look.
|
||||
fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f32, look: Option<&str>) -> Option<Value> {
|
||||
fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f64, look: Option<&str>) -> Option<Value> {
|
||||
let bodies: Vec<String> = targets.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
|
||||
let faces: Vec<Value> = targets
|
||||
.iter()
|
||||
@@ -139,9 +176,7 @@ fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f32, look: Option<&str
|
||||
p["bodies"] = json!(owners);
|
||||
}
|
||||
}
|
||||
if opacity < 0.999 {
|
||||
p["opacity"] = json!((opacity * 1000.0).round() / 1000.0);
|
||||
}
|
||||
p["opacity"] = json!(opacity);
|
||||
if let Some(n) = look {
|
||||
p["appearance"] = json!(n);
|
||||
}
|
||||
@@ -150,8 +185,35 @@ fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f32, look: Option<&str
|
||||
|
||||
/// The commands OK runs: the current look on the picked objects (nothing when none are picked).
|
||||
pub fn commands(k: &Ap, inputs: &[SelInput]) -> Vec<(String, Value)> {
|
||||
// Remove override already ran immediately; OK closes without creating a second undo step.
|
||||
if k.remove_override {
|
||||
return Vec::new();
|
||||
}
|
||||
let items = inputs.first().map(|i| i.items.as_slice()).unwrap_or(&[]);
|
||||
apply_params(items, k.color, k.opacity, k.look.as_deref()).map(|p| vec![("appearance.assign".to_string(), p)]).unwrap_or_default()
|
||||
let p = selected_params(k, items);
|
||||
if p.as_ref().is_some_and(|p| k.applied.as_ref() == Some(p)) {
|
||||
return Vec::new();
|
||||
}
|
||||
p.map(|p| vec![("appearance.assign".to_string(), p)]).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn selected_params(k: &Ap, items: &[Sel]) -> Option<Value> {
|
||||
if let Some(component) = &k.component {
|
||||
Some(json!({"components": [component], "color": hex(k.color), "opacity": k.opacity, "appearance": k.look}))
|
||||
} else {
|
||||
apply_params(items, k.color, k.opacity, k.look.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// Removing an override restores the inherited component or material appearance.
|
||||
fn clear_params(k: &Ap, items: &[Sel]) -> Option<Value> {
|
||||
let mut p = selected_params(k, items)?;
|
||||
if let Some(obj) = p.as_object_mut() {
|
||||
obj.remove("appearance");
|
||||
obj.remove("color");
|
||||
obj.remove("opacity");
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// What a swatch dropped now would land on: the face or body under the cursor in the viewport,
|
||||
@@ -166,32 +228,43 @@ fn drop_target(app: &SolveApp, faces: bool) -> Vec<Sel> {
|
||||
|
||||
/// The looks in this design (colour and how many items wear it): the engine's list when it has
|
||||
/// one, else the body colours.
|
||||
fn in_design(app: &mut SolveApp) -> Vec<([u8; 3], usize)> {
|
||||
fn in_design(app: &mut SolveApp) -> Vec<(Look, usize)> {
|
||||
if let Ok(v) = app.session.execute("appearance.list", &json!({}))
|
||||
&& let Some(list) = v.get("in_design").and_then(Value::as_array)
|
||||
{
|
||||
let count = |x: &Value, k: &str| x.get(k).and_then(Value::as_array).map_or(0, Vec::len);
|
||||
return list
|
||||
.iter()
|
||||
.filter_map(|x| Some((parse_color(x.get("color")?)?, count(x, "bodies") + count(x, "faces") + count(x, "components"))))
|
||||
.filter_map(|x| {
|
||||
Some((
|
||||
Look {
|
||||
name: x.get("name").and_then(Value::as_str).unwrap_or("Custom").to_string(),
|
||||
category: "In This Design".into(),
|
||||
color: parse_color(x.get("color")?)?,
|
||||
opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0),
|
||||
},
|
||||
count(x, "bodies") + count(x, "faces") + count(x, "components"),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let mut out: Vec<([u8; 3], usize)> = Vec::new();
|
||||
for c in app.session.doc.appearances.bodies.values().map(|l| l.color) {
|
||||
match out.iter_mut().find(|(x, _)| *x == c) {
|
||||
let mut out: Vec<(Look, usize)> = Vec::new();
|
||||
for l in app.session.doc.appearances.bodies.values() {
|
||||
let look = Look { name: l.name.clone(), category: "In This Design".into(), color: l.color, opacity: l.opacity };
|
||||
match out.iter_mut().find(|(x, _)| *x == look) {
|
||||
Some(e) => e.1 += 1,
|
||||
None => out.push((c, 1)),
|
||||
None => out.push((look, 1)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn c32(c: [u8; 3], opacity: f32) -> Color32 {
|
||||
fn c32(c: [u8; 3], opacity: f64) -> Color32 {
|
||||
Color32::from_rgba_unmultiplied(c[0], c[1], c[2], (opacity.clamp(0.0, 1.0) * 255.0) as u8)
|
||||
}
|
||||
|
||||
/// A swatch: a rounded square of the look; returns its response (click, drag).
|
||||
fn swatch(ui: &mut egui::Ui, id: egui::Id, color: [u8; 3], opacity: f32, picked: bool, tip: &str, payload: Look) -> egui::Response {
|
||||
fn swatch(ui: &mut egui::Ui, id: egui::Id, color: [u8; 3], opacity: f64, picked: bool, tip: &str, payload: Look) -> egui::Response {
|
||||
let t = Tokens::get();
|
||||
let r = ui
|
||||
.dnd_drag_source(id, payload, |ui| {
|
||||
@@ -200,7 +273,7 @@ fn swatch(ui: &mut egui::Ui, id: egui::Id, color: [u8; 3], opacity: f32, picked:
|
||||
if opacity < 0.999 {
|
||||
let h = rect.width() / 2.0;
|
||||
for (dx, dy) in [(0.0, 0.0), (h, h)] {
|
||||
ui.painter().rect_filled(egui::Rect::from_min_size(rect.min + vec2(dx, dy), vec2(h, h)), 0.0, Color32::from_gray(200));
|
||||
ui.painter().rect_filled(egui::Rect::from_min_size(rect.min + vec2(dx, dy), vec2(h, h)), 0.0, t.hover);
|
||||
}
|
||||
}
|
||||
ui.painter().rect(
|
||||
@@ -238,13 +311,23 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
|
||||
let t = Tokens::get();
|
||||
// Apply To: bodies or faces (the input follows).
|
||||
row_label(ui, "Apply To");
|
||||
let mut mode = usize::from(k.faces);
|
||||
combo(ui, "ap_mode", &["Bodies/Components", "Faces"], &mut mode);
|
||||
ui.end_row();
|
||||
if (mode == 1) != k.faces {
|
||||
k.faces = mode == 1;
|
||||
if let Some(i) = inputs.first_mut() {
|
||||
*i = SelInput::new("Objects", if k.faces { FACES } else { BODIES }, true);
|
||||
if let Some(component) = &k.component {
|
||||
ui.label(format!("Component: {component}"));
|
||||
ui.end_row();
|
||||
row_label(ui, "");
|
||||
if ui.button("Select bodies or faces instead").clicked() {
|
||||
k.component = None;
|
||||
}
|
||||
ui.end_row();
|
||||
} else {
|
||||
let mut mode = usize::from(k.faces);
|
||||
combo(ui, "ap_mode", &["Bodies", "Faces"], &mut mode);
|
||||
ui.end_row();
|
||||
if (mode == 1) != k.faces {
|
||||
k.faces = mode == 1;
|
||||
if let Some(i) = inputs.first_mut() {
|
||||
*i = SelInput::new("Objects", if k.faces { FACES } else { BODIES }, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The current look: colour and opacity.
|
||||
@@ -254,35 +337,72 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
|
||||
if ui.color_edit_button_srgb(&mut rgb).changed() {
|
||||
k.color = rgb;
|
||||
k.look = None;
|
||||
k.remove_override = false;
|
||||
}
|
||||
ui.label(RichText::new(k.look.clone().unwrap_or_else(|| hex(k.color))).color(t.text_dim));
|
||||
});
|
||||
ui.end_row();
|
||||
row_label(ui, "Opacity");
|
||||
ui.spacing_mut().slider_width = 110.0;
|
||||
ui.add(egui::Slider::new(&mut k.opacity, 0.05..=1.0).fixed_decimals(2));
|
||||
// Decimal formatting must not round a saved look merely by drawing its editor.
|
||||
if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).max_decimals_opt(None).custom_formatter(|value, _| format!("{value:.2}"))).changed() {
|
||||
k.remove_override = false;
|
||||
}
|
||||
ui.end_row();
|
||||
row_label(ui, "");
|
||||
let picked: Vec<Sel> = inputs.first().map(|i| i.items.clone()).unwrap_or_default();
|
||||
if ui.add_enabled(!picked.is_empty(), egui::Button::new("Apply to selection")).clicked()
|
||||
&& let Some(p) = apply_params(&picked, k.color, k.opacity, k.look.as_deref())
|
||||
{
|
||||
k.note = Some(match app.run("appearance.assign", p) {
|
||||
Ok(_) => format!("applied to {} item(s)", picked.len()),
|
||||
Err(e) => e,
|
||||
});
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let has_target = !picked.is_empty() || k.component.is_some();
|
||||
if ui.add_enabled(has_target, egui::Button::new("Apply to selection")).clicked()
|
||||
&& let Some(p) = selected_params(k, &picked)
|
||||
{
|
||||
k.remove_override = false;
|
||||
k.note = Some(match app.run("appearance.assign", p.clone()) {
|
||||
Ok(_) => {
|
||||
k.applied = Some(p);
|
||||
"Appearance applied".into()
|
||||
}
|
||||
Err(e) => e,
|
||||
});
|
||||
}
|
||||
if ui
|
||||
.add_enabled(has_target, egui::Button::new("Remove override"))
|
||||
.on_hover_text("Restore the inherited component or physical material appearance")
|
||||
.clicked()
|
||||
&& let Some(p) = clear_params(k, &picked)
|
||||
{
|
||||
k.note = Some(match app.run("appearance.assign", p) {
|
||||
Ok(_) => {
|
||||
k.remove_override = true;
|
||||
k.applied = None;
|
||||
"Inherited appearance restored".into()
|
||||
}
|
||||
Err(e) => e,
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
// In this design.
|
||||
let used = in_design(app);
|
||||
if !used.is_empty() {
|
||||
row_label(ui, "In This Design");
|
||||
let items: Vec<(usize, &([u8; 3], usize))> = used.iter().enumerate().collect();
|
||||
swatch_rows(ui, &items, |ui, (i, (c, n))| {
|
||||
let look = Look { name: hex(*c), category: "In This Design".into(), color: *c, opacity: 1.0 };
|
||||
if swatch(ui, egui::Id::new(("ap_used", *i)), *c, 1.0, k.color == *c, &format!("{} · {n} item(s)", hex(*c)), look).clicked() {
|
||||
k.color = *c;
|
||||
k.look = None;
|
||||
let items: Vec<_> = used.iter().enumerate().collect();
|
||||
swatch_rows(ui, &items, |ui, (i, (look, n))| {
|
||||
if swatch(
|
||||
ui,
|
||||
egui::Id::new(("ap_used", *i)),
|
||||
look.color,
|
||||
look.opacity,
|
||||
k.color == look.color && k.opacity == look.opacity,
|
||||
&format!("{} · {n} item(s)", look.name),
|
||||
look.clone(),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
k.color = look.color;
|
||||
k.opacity = look.opacity;
|
||||
k.look = Some(look.name.clone());
|
||||
k.remove_override = false;
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
@@ -299,9 +419,18 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
|
||||
row_label(ui, "Library");
|
||||
combo(ui, "ap_cat", &refs, &mut k.category);
|
||||
ui.end_row();
|
||||
row_label(ui, "Search");
|
||||
let search = ui.add(egui::TextEdit::singleline(&mut k.search).hint_text("Appearance name").desired_width(190.0));
|
||||
crate::scenario::publish_handle("appearance:search", search.rect.center());
|
||||
ui.end_row();
|
||||
row_label(ui, "");
|
||||
let want = cats.get(k.category).cloned().unwrap_or_default();
|
||||
let shown: Vec<(usize, &Look)> = lib.iter().enumerate().filter(|(_, l)| k.category == 0 || l.category == want).collect();
|
||||
let query = k.search.trim().to_lowercase();
|
||||
let shown: Vec<(usize, &Look)> =
|
||||
lib.iter().enumerate().filter(|(_, l)| (k.category == 0 || l.category == want) && l.name.to_lowercase().contains(&query)).collect();
|
||||
if shown.is_empty() {
|
||||
ui.label(RichText::new("No matching appearances").color(t.text_dim));
|
||||
}
|
||||
swatch_rows(ui, &shown, |ui, (i, l)| {
|
||||
let picked_look = k.look.as_deref() == Some(l.name.as_str());
|
||||
if swatch(ui, egui::Id::new(("ap_lib", *i)), l.color, l.opacity, picked_look, &format!("{} (drag onto a body or face)", l.name), (*l).clone())
|
||||
@@ -310,6 +439,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
|
||||
k.color = l.color;
|
||||
k.opacity = l.opacity;
|
||||
k.look = Some(l.name.clone());
|
||||
k.remove_override = false;
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
@@ -319,10 +449,14 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
|
||||
let targets = drop_target(app, k.faces);
|
||||
k.color = look.color;
|
||||
k.opacity = look.opacity;
|
||||
k.look = (look.category != "In This Design").then(|| look.name.clone());
|
||||
k.look = Some(look.name.clone());
|
||||
k.remove_override = false;
|
||||
if let Some(p) = apply_params(&targets, look.color, look.opacity, k.look.as_deref()) {
|
||||
k.note = Some(match app.run("appearance.assign", p) {
|
||||
Ok(_) => format!("{} on {}", look.name, target_names(&targets)),
|
||||
k.note = Some(match app.run("appearance.assign", p.clone()) {
|
||||
Ok(_) => {
|
||||
k.applied = Some(p);
|
||||
format!("{} on {}", look.name, target_names(&targets))
|
||||
}
|
||||
Err(e) => e,
|
||||
});
|
||||
}
|
||||
@@ -392,7 +526,11 @@ mod tests {
|
||||
a.run(&id, p).unwrap();
|
||||
}
|
||||
assert_eq!(a.session.doc.appearances.bodies.get("Body2").map(|l| l.color), Some([196, 40, 36]));
|
||||
assert_eq!(in_design(&mut a), vec![([196, 40, 36], 2)]);
|
||||
let used = in_design(&mut a);
|
||||
assert_eq!(used.len(), 1);
|
||||
assert_eq!(used[0].0.color, [196, 40, 36]);
|
||||
assert_eq!(used[0].0.name, "Paint - Red");
|
||||
assert_eq!(used[0].1, 2);
|
||||
}
|
||||
|
||||
/// A swatch dropped on a face in face mode targets the face; otherwise its body.
|
||||
@@ -411,4 +549,90 @@ mod tests {
|
||||
assert_eq!(face["opacity"], 0.5);
|
||||
assert!(!library(&mut a).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_preselection_keeps_the_face_and_remove_survives_ok() {
|
||||
let mut a = app();
|
||||
let point = Vec3::new(5.0, 5.0, 10.0);
|
||||
let selected = vec![Sel::Face { body: "Body1".into(), index: 0, point }];
|
||||
a.session.selection = selected.clone();
|
||||
a.start("appearance.assign");
|
||||
let mut d = a.dialog.clone().unwrap();
|
||||
assert_eq!(d.inputs[0].items, selected);
|
||||
let Kind::Appearance(k) = &mut d.kind else { panic!("appearance dialog") };
|
||||
assert!(k.faces);
|
||||
k.color = [10, 20, 30];
|
||||
k.opacity = 0.25;
|
||||
for (id, p) in commands(k, &d.inputs) {
|
||||
a.run(&id, p).unwrap();
|
||||
}
|
||||
assert_eq!(a.session.doc.appearances.faces.len(), 1);
|
||||
assert!(a.session.doc.appearances.bodies.is_empty());
|
||||
a.run("appearance.assign", clear_params(k, &d.inputs[0].items).unwrap()).unwrap();
|
||||
k.remove_override = true;
|
||||
assert!(commands(k, &d.inputs).is_empty());
|
||||
assert!(a.session.doc.appearances.faces.is_empty());
|
||||
a.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(a.session.doc.appearances.faces.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_design_look_preserves_transparency_and_name() {
|
||||
let mut a = app();
|
||||
a.run("appearance.assign", json!({"bodies": ["Body1"], "color": "#abcdef", "appearance": "Tinted glass", "opacity": 0.25})).unwrap();
|
||||
let used = in_design(&mut a);
|
||||
let look = &used[0].0;
|
||||
assert_eq!(look.opacity, 0.25);
|
||||
assert_eq!(look.name, "Tinted glass");
|
||||
a.session.selection = vec![Sel::Body { name: "Body1".into() }];
|
||||
a.start("appearance.assign");
|
||||
let Kind::Appearance(k) = &a.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") };
|
||||
assert_eq!(k.opacity, 0.25);
|
||||
assert_eq!(k.look.as_deref(), Some("Tinted glass"));
|
||||
let p = apply_params(&[Sel::Body { name: "Body2".into() }], look.color, look.opacity, Some(&look.name)).unwrap();
|
||||
a.run("appearance.assign", p).unwrap();
|
||||
assert_eq!(a.session.doc.appearances.bodies.get("Body1"), a.session.doc.appearances.bodies.get("Body2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_frames_and_unchanged_apply_keep_precise_opacity() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":"#abcdef","opacity":0.123456})).unwrap();
|
||||
for cancel in [true, false] {
|
||||
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
|
||||
h.app.start("appearance.assign");
|
||||
h.frames(3);
|
||||
let Kind::Appearance(appearance) = &h.app.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") };
|
||||
assert_eq!(appearance.opacity, 0.123456);
|
||||
h.step(&json!({"key":if cancel {"Escape"} else {"Enter"}})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
|
||||
assert_eq!(h.app.session.doc.appearances.bodies["Body1"].opacity, 0.123456);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immediate_apply_then_ok_has_one_undo_and_retains_precise_opacity() {
|
||||
let mut a = app();
|
||||
a.run("appearance.assign", json!({"bodies": ["Body1"], "color": "#abcdef", "opacity": 0.123456})).unwrap();
|
||||
a.session.selection = vec![Sel::Body { name: "Body1".into() }];
|
||||
a.start("appearance.assign");
|
||||
let mut d = a.dialog.clone().unwrap();
|
||||
let Kind::Appearance(k) = &mut d.kind else { panic!("appearance dialog") };
|
||||
assert_eq!(commands(k, &d.inputs)[0].1["opacity"], json!(0.123456));
|
||||
k.color = [1, 2, 3];
|
||||
let p = selected_params(k, &d.inputs[0].items).unwrap();
|
||||
a.run("appearance.assign", p.clone()).unwrap();
|
||||
k.applied = Some(p);
|
||||
assert!(commands(k, &d.inputs).is_empty());
|
||||
a.run("edit.undo", json!({})).unwrap();
|
||||
let restored = a.session.doc.appearances.bodies.get("Body1").unwrap();
|
||||
assert_eq!(restored.color, [171, 205, 239]);
|
||||
assert_eq!(restored.opacity, 0.123456);
|
||||
k.opacity = 0.5;
|
||||
assert_eq!(commands(k, &d.inputs).len(), 1, "changed look still applies on OK");
|
||||
k.opacity = 0.123456;
|
||||
d.inputs[0].items = vec![Sel::Body { name: "Body2".into() }];
|
||||
assert_eq!(commands(k, &d.inputs).len(), 1, "changed target still applies on OK");
|
||||
}
|
||||
}
|
||||
@@ -273,7 +273,7 @@ fn pt(v: Vec3) -> Value {
|
||||
fn occurrence_of_body(s: &Session, body: &str) -> Option<u64> {
|
||||
let st = s.world_state();
|
||||
let b = st.body(body)?;
|
||||
let comp = s.doc.body_component(&b.name, b.feature);
|
||||
let comp = s.doc.model_body_component(b);
|
||||
if comp == 0 { Some(0) } else { s.doc.occurrence_of(comp).map(|o| o.id) }
|
||||
}
|
||||
|
||||
@@ -819,13 +819,11 @@ pub fn world_preview(s: &Session, cmds: &[(String, Value)], colors: Colors) -> O
|
||||
if cmds.is_empty() || !(moves || solvecraft_engine::frames::placed(s)) {
|
||||
return None;
|
||||
}
|
||||
let mut scratch = s.scratch();
|
||||
for (id, p) in cmds {
|
||||
if let Err(e) = scratch.execute(id, p) {
|
||||
return Some(Err(e.to_string()));
|
||||
}
|
||||
}
|
||||
let (before, after) = (s.world_state(), scratch.world_state());
|
||||
let preview = match s.preview_visible_world(cmds) {
|
||||
Ok(preview) => preview,
|
||||
Err(error) => return Some(Err(error.to_string())),
|
||||
};
|
||||
let (before, after) = (preview.before, preview.after);
|
||||
// A moved component looks like itself (no new surface), with its old place as the ghost.
|
||||
let colors = Colors { added: colors.body, ..colors };
|
||||
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::preview::build(&before, &after, colors)));
|
||||
|
||||
@@ -16,6 +16,9 @@ use crate::dialogs::{Dialog, Kind, combo, field, row_label};
|
||||
use crate::selection::{BODIES, SelInput};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
#[path = "configuration_rules_ui.rs"]
|
||||
mod configuration_rules_ui;
|
||||
|
||||
/// One keyed value of a motion study: a joint's value (index) at steps.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Track {
|
||||
@@ -60,6 +63,7 @@ pub enum Mo {
|
||||
Configurations {
|
||||
new_row: String,
|
||||
error: Option<String>,
|
||||
rule_editor: configuration_rules_ui::Editor,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -114,7 +118,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
vec![],
|
||||
),
|
||||
"explode.create" => (Mo::Explode { name: n("Exploded View", "explode.list"), scale: 1.0, shown: None, saved: None, error: None }, vec![]),
|
||||
"config.table" | "config.configure" => (Mo::Configurations { new_row: String::new(), error: None }, vec![]),
|
||||
"config.table" | "config.configure" => (Mo::Configurations { new_row: String::new(), error: None, rule_editor: Default::default() }, vec![]),
|
||||
_ => return None,
|
||||
};
|
||||
Some((Kind::Motion(mo), inputs))
|
||||
@@ -263,7 +267,11 @@ pub fn column_parts(c: &str) -> (&str, &str) {
|
||||
/// The value a table cell edit sends: suppress columns take booleans, parameters expressions.
|
||||
pub fn cell_value(column: &str, text: &str) -> Value {
|
||||
match column_parts(column).0 {
|
||||
"suppress" => json!(matches!(text.trim().to_ascii_lowercase().as_str(), "suppressed" | "true" | "yes" | "1" | "off")),
|
||||
"suppress" => match text.trim().to_ascii_lowercase().as_str() {
|
||||
"suppressed" | "true" | "yes" | "1" | "off" => json!(true),
|
||||
"unsuppressed" | "false" | "no" | "0" | "on" => json!(false),
|
||||
_ => json!(text),
|
||||
},
|
||||
_ => json!(text),
|
||||
}
|
||||
}
|
||||
@@ -578,8 +586,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
Mo::Configurations { new_row, error } => {
|
||||
let v = app.session.execute("config.table", &json!({})).unwrap_or(Value::Null);
|
||||
Mo::Configurations { new_row, error, rule_editor } => {
|
||||
let revision = app.session.revision;
|
||||
let v = rule_editor.table.as_ref().filter(|(r, _)| *r == revision).map(|(_, v)| v.clone()).unwrap_or_else(|| {
|
||||
let value = app.session.execute("config.table", &json!({})).unwrap_or(Value::Null);
|
||||
rule_editor.table = Some((revision, value.clone()));
|
||||
value
|
||||
});
|
||||
let mut tb = table(&v);
|
||||
// Bare numbers show with their parameter's unit ("40" is 40 mm), as Fusion shows them.
|
||||
let kinds = app.session.doc.all_param_exprs();
|
||||
@@ -588,7 +601,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
let (kind, n) = column_parts(c);
|
||||
if kind == "param"
|
||||
&& val.trim().parse::<f64>().is_ok()
|
||||
&& let Some(unit) = kinds.iter().find(|x| x.0 == n).and_then(|x| unit_of(x.2))
|
||||
&& let Some(unit) = app.session.doc.param(n).map(|parameter| parameter.unit.clone()).or_else(|| {
|
||||
kinds.iter().find(|x| x.0 == n).and_then(|x| {
|
||||
unit_of(x.2).map(|unit| {
|
||||
if x.2 == solvecraft_engine::doc::expr::Kind::Length { app.session.doc.units.clone() } else { unit.to_string() }
|
||||
})
|
||||
})
|
||||
})
|
||||
{
|
||||
*val = format!("{} {unit}", val.trim());
|
||||
}
|
||||
@@ -597,56 +616,137 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
let run = |app: &mut SolveApp, error: &mut Option<String>, id: &str, p: Value| {
|
||||
*error = app.run(id, p).err();
|
||||
};
|
||||
// Header.
|
||||
ui.label(RichText::new("Configuration").strong());
|
||||
ui.horizontal(|ui| {
|
||||
for c in &tb.columns {
|
||||
let (kind, n) = column_parts(c);
|
||||
ui.add_sized(vec2(96.0, 18.0), egui::Label::new(RichText::new(n).strong()).truncate())
|
||||
.on_hover_text(format!("{} {n}", if kind == "suppress" { "Suppress" } else { "Parameter" }));
|
||||
if ui.small_button("×").on_hover_text("Remove the column").clicked() {
|
||||
let key = if kind == "suppress" { "feature" } else { "param" };
|
||||
run(app, error, "config.column", json!({ key: n, "delete": true }));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
for (name, vals) in &tb.rows {
|
||||
let active = tb.active.as_deref() == Some(name.as_str());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(if active { RichText::new(name).strong() } else { RichText::new(name) });
|
||||
if active {
|
||||
ui.label(RichText::new("Active").color(t.accent));
|
||||
} else if ui.button("Activate").clicked() {
|
||||
run(app, error, "config.activate", json!({ "row": name }));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
for (c, val) in tb.columns.iter().zip(vals) {
|
||||
let id = egui::Id::new(("cfg", name, c));
|
||||
let mut text = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| val.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(96.0));
|
||||
if r.changed() {
|
||||
ui.data_mut(|d| d.insert_temp(id, text.clone()));
|
||||
egui::ScrollArea::horizontal().id_salt("configuration_columns").auto_shrink([false, true]).show(ui, |ui| {
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
|
||||
egui::Grid::new("configuration_table").num_columns(2).min_col_width(210.0).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
|
||||
// Header.
|
||||
ui.label(RichText::new("Configuration").strong());
|
||||
ui.horizontal(|ui| {
|
||||
for c in &tb.columns {
|
||||
ui.allocate_ui_with_layout(vec2(190.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
let (kind, n) = column_parts(c);
|
||||
let label = match kind {
|
||||
"material" => format!("Material: {n}"),
|
||||
"appearance" => format!("Appearance: {n}"),
|
||||
"suppress" => format!("Suppress: {n}"),
|
||||
_ => n.to_string(),
|
||||
};
|
||||
let label = v["targets"]
|
||||
.as_array()
|
||||
.and_then(|targets| targets.iter().find(|target| target["key"].as_str() == Some(c)))
|
||||
.and_then(|target| target["label"].as_str())
|
||||
.filter(|_| matches!(kind, "component_appearance" | "face_appearance"))
|
||||
.map(str::to_string)
|
||||
.unwrap_or(label);
|
||||
ui.add_sized(vec2(156.0, 22.0), egui::Label::new(RichText::new(label).strong()).truncate()).on_hover_text(c);
|
||||
if ui.small_button("×").on_hover_text("Remove the column").clicked() {
|
||||
let key = if kind == "suppress" { "feature" } else { kind };
|
||||
run(
|
||||
app,
|
||||
error,
|
||||
"config.column",
|
||||
json!({ key: if kind == "face_appearance" {c.as_str()} else {n}, "delete": true }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
if r.lost_focus() {
|
||||
ui.data_mut(|d| d.remove::<String>(id));
|
||||
if text != *val {
|
||||
let (_, n) = column_parts(c);
|
||||
run(app, error, "config.row", json!({"name": name, "values": { n: cell_value(c, &text) }}));
|
||||
});
|
||||
ui.end_row();
|
||||
for (name, vals) in &tb.rows {
|
||||
let active = tb.active.as_deref() == Some(name.as_str());
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(
|
||||
vec2(100.0, 22.0),
|
||||
egui::Label::new(if active { RichText::new(name).strong() } else { RichText::new(name) }).truncate(),
|
||||
)
|
||||
.on_hover_text(name);
|
||||
if active {
|
||||
ui.label(RichText::new("Active").color(t.accent));
|
||||
if v.get("matches").and_then(Value::as_bool) == Some(false) && ui.button("Apply").clicked() {
|
||||
run(app, error, "config.activate", json!({ "row": name }));
|
||||
}
|
||||
} else if ui.button("Activate").clicked() {
|
||||
run(app, error, "config.activate", json!({ "row": name }));
|
||||
}
|
||||
}
|
||||
ui.add_space(18.0);
|
||||
}
|
||||
if ui.small_button("×").on_hover_text("Delete the row").clicked() {
|
||||
run(app, error, "config.row", json!({"name": name, "delete": true}));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
for (c, val) in tb.columns.iter().zip(vals) {
|
||||
ui.allocate_ui_with_layout(vec2(190.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
let (kind, _) = column_parts(c);
|
||||
let appearance = matches!(kind, "appearance" | "component_appearance" | "face_appearance");
|
||||
let shown = if appearance {
|
||||
solvecraft_engine::doc::config::appearance_cell(val)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|look| solvecraft_engine::doc::appearance::library(&look.name).as_ref() == Some(look))
|
||||
.map(|look| look.name)
|
||||
.unwrap_or_else(|| val.clone())
|
||||
} else {
|
||||
val.clone()
|
||||
};
|
||||
let style = kind == "material" || appearance;
|
||||
if style {
|
||||
let mut choice = None;
|
||||
ui.menu_button("Library", |ui| {
|
||||
if ui
|
||||
.button(if kind == "appearance" {
|
||||
"Follow material"
|
||||
} else if appearance {
|
||||
"Follow inherited appearance"
|
||||
} else {
|
||||
"Default material"
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
choice = Some("Default".to_string());
|
||||
ui.close();
|
||||
}
|
||||
if kind == "material" {
|
||||
for (name, _) in solvecraft_engine::doc::MATERIALS.iter().filter(|(n, _)| *n != "Default") {
|
||||
if ui.button(*name).clicked() {
|
||||
choice = Some(name.to_string());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (name, _, _) in solvecraft_engine::doc::appearance::LIBRARY {
|
||||
if ui.button(name).clicked() {
|
||||
choice = Some(name.to_string());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(choice) = choice {
|
||||
run(app, error, "config.row", json!({"name": name, "values": { c: choice }}));
|
||||
}
|
||||
}
|
||||
let id = (name.clone(), c.clone());
|
||||
let mut text = rule_editor.drafts.get(&id).cloned().unwrap_or_else(|| shown.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(if style { 106.0 } else { 164.0 }));
|
||||
if r.changed() {
|
||||
rule_editor.drafts.insert(id.clone(), text.clone());
|
||||
}
|
||||
if r.lost_focus() {
|
||||
rule_editor.drafts.remove(&id);
|
||||
if text != shown {
|
||||
run(app, error, "config.row", json!({"name": name, "values": { c: cell_value(c, &text) }}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if ui.small_button("×").on_hover_text("Delete the row").clicked() {
|
||||
run(app, error, "config.row", json!({"name": name, "delete": true}));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
// New row (a copy of the active one) and new column.
|
||||
ui.add(egui::TextEdit::singleline(new_row).hint_text("new configuration").desired_width(130.0));
|
||||
ui.horizontal(|ui| {
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
|
||||
ui.add(egui::TextEdit::singleline(new_row).hint_text("new configuration").desired_width(160.0));
|
||||
if ui.button("Add Row").on_hover_text("A new configuration, a copy of the active one").clicked() {
|
||||
let name = if new_row.trim().is_empty() {
|
||||
(tb.rows.len() + 1..).map(|k| format!("Configuration{k}")).find(|n| !tb.rows.iter().any(|r| &r.0 == n)).unwrap_or_default()
|
||||
@@ -672,6 +772,24 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
options.push((format!("Suppress {}", f.name), json!({ "feature": f.name })));
|
||||
}
|
||||
}
|
||||
for body in &app.session.model.state().bodies {
|
||||
for kind in ["material", "appearance"] {
|
||||
if !tb.columns.contains(&format!("{kind}:{}", body.name)) {
|
||||
options.push((format!("{} ({kind})", body.name), json!({kind: body.name})));
|
||||
}
|
||||
}
|
||||
}
|
||||
for component in &app.session.doc.components {
|
||||
if component.id != 0 && !tb.columns.contains(&format!("component_appearance:{}", component.id)) {
|
||||
options.push((format!("{} (component appearance)", component.name), json!({"component_appearance":component.id})));
|
||||
}
|
||||
}
|
||||
for selected in &app.session.selection {
|
||||
if let solvecraft_engine::Sel::Face { body, point, .. } = selected {
|
||||
options
|
||||
.push((format!("Selected face on {body} (shared appearance)"), json!({"face_appearance":{"body":body,"point":point}})));
|
||||
}
|
||||
}
|
||||
let mut pick: Option<Value> = None;
|
||||
ui.menu_button("Add Column", |ui| {
|
||||
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
|
||||
@@ -681,6 +799,50 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.menu_button("Face Appearance", |ui| {
|
||||
ui.label("Semantic faces share their appearance across instances.");
|
||||
let state = app.session.model.state();
|
||||
for body in &state.bodies {
|
||||
if body.body.is_mesh() {
|
||||
continue;
|
||||
}
|
||||
ui.menu_button(format!("Faces on {}", body.name), |ui| {
|
||||
if let Ok(faces) = body.body.faces((body.body.size() * 2e-3).max(1e-3)) {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mesh = body.mesh();
|
||||
let mut points = std::collections::BTreeMap::new();
|
||||
for (triangle, index) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if let std::collections::btree_map::Entry::Vacant(entry) = points.entry(*index as usize)
|
||||
&& let Some([a, b, c]) = mesh.tri(triangle)
|
||||
{
|
||||
entry.insert((a + b + c) * (1. / 3.));
|
||||
}
|
||||
}
|
||||
for face in faces {
|
||||
// A curved face centroid can lie on another surface.
|
||||
// Pick an interior triangle point on this actual face.
|
||||
let Some(point) = points.get(&face.index).copied() else { continue };
|
||||
let Ok(target) = app.session.doc.configuration_face_target(body, face.index, point) else { continue };
|
||||
let supported = if target.certificate.is_some() {
|
||||
// Capture read this exact face's current source certificate.
|
||||
// Enumerate its pieces once in the command, not per menu face.
|
||||
app.session.doc.certify_config_face(&target).is_ok()
|
||||
} else {
|
||||
app.session.doc.configuration_face_indices(body, &target).is_ok()
|
||||
};
|
||||
if !supported || !seen.insert(target.name.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Some((_, role)) = target.name.split_once(':') else { continue };
|
||||
if ui.button(role).clicked() {
|
||||
pick = Some(json!({"face_appearance":{"target":target}}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(p) = pick {
|
||||
@@ -688,9 +850,18 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
let (id, p) = if tb.columns.is_empty() {
|
||||
(
|
||||
"config.configure",
|
||||
match p.get("param") {
|
||||
Some(n) => json!({ "params": [n] }),
|
||||
None => json!({ "features": [p.get("feature")] }),
|
||||
if let Some(n) = p.get("param") {
|
||||
json!({ "params": [n] })
|
||||
} else if let Some(n) = p.get("feature") {
|
||||
json!({ "features": [n] })
|
||||
} else if let Some(n) = p.get("material") {
|
||||
json!({ "materials": [n] })
|
||||
} else if let Some(n) = p.get("component_appearance") {
|
||||
json!({ "component_appearances": [n] })
|
||||
} else if let Some(n) = p.get("face_appearance") {
|
||||
json!({ "face_appearances": [n] })
|
||||
} else {
|
||||
json!({ "appearances": [p.get("appearance")] })
|
||||
},
|
||||
)
|
||||
} else {
|
||||
@@ -700,6 +871,21 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
configuration_rules_ui::show(ui, app, error, rule_editor);
|
||||
if let Some(styles) = v["style_status"].as_array() {
|
||||
for style in styles.iter().filter(|style| style["dormant"].as_bool() == Some(true)) {
|
||||
ui.add(
|
||||
egui::Label::new(format!(
|
||||
"{}: appearance dormant while its feature is suppressed",
|
||||
style["target"].as_str().unwrap_or("Face")
|
||||
))
|
||||
.wrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(message) = v["style_error"].as_str() {
|
||||
ui.add(egui::Label::new(RichText::new(message).color(t.error)).wrap());
|
||||
}
|
||||
if let Some(e) = error {
|
||||
ui.label("");
|
||||
ui.label(RichText::new(e.as_str()).color(t.error));
|
||||
@@ -747,7 +933,7 @@ pub fn commands(app: &SolveApp, k: &Mo, inputs: &[SelInput]) -> Result<Vec<(Stri
|
||||
if let solvecraft_engine::Sel::Body { name } = x
|
||||
&& let Some(b) = app.session.world_state().body(name)
|
||||
{
|
||||
let comp = app.session.doc.body_component(&b.name, b.feature);
|
||||
let comp = app.session.doc.model_body_component(b);
|
||||
if let Some(o) = app.session.doc.occurrence_of(comp).map(|o| o.id)
|
||||
&& !occs.contains(&o)
|
||||
{
|
||||
@@ -837,6 +1023,224 @@ mod tests {
|
||||
assert_eq!(cell_value("param:width", "90 mm"), json!("90 mm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_compound_condition_and_rule_order_are_editable_in_dialog() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.resize", json!({"width":1400,"height":1100}));
|
||||
h.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"parameters.change","params":{"name":"length","expression":"40 mm"}}},
|
||||
{"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}},
|
||||
{"start":"config.table"},{"widget":"Add or edit rule"},
|
||||
{"widget":"Always"},{"widget":"All"},{"widget":"Always"},{"widget":"Parameter"},
|
||||
{"widget":"Choose…"},{"widget":"length"},{"widget":"Save Rule"}
|
||||
]))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&h.app.session.doc.configs.rules[0].condition,solvecraft_engine::doc::config::RuleCondition::All{conditions} if matches!(&conditions[0],solvecraft_engine::doc::config::RuleCondition::Parameter{..}))
|
||||
);
|
||||
h.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"config.rule","params":{"rule":{"id":"last","name":"Last drive","condition":{"type":"always"},"effect":{"type":"drive_parameter","parameter":"length","expression":"20 mm"}}}}},
|
||||
{"widget":"Preview Row"},{"widget":"Preview: Default"}
|
||||
])).unwrap();
|
||||
assert!(h.widget("length = 20 mm").is_some());
|
||||
assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm");
|
||||
h.run(&json!([{"widget":"↑"},{"widget":"Preview Row"}])).unwrap();
|
||||
assert_eq!(h.app.session.doc.configs.rules[0].id, "last");
|
||||
assert!(h.widget("length = 10 mm").is_some());
|
||||
assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rule_editor_creates_previews_edits_and_applies_driven_value() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.resize", json!({"width":1400,"height":1000}));
|
||||
h.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"parameters.change","params":{"name":"length","expression":"40 mm"}}},
|
||||
{"call":"engine.execute","params":{"command":"solid.box","params":{"length":"length","width":10,"height":10}}},
|
||||
{"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}},
|
||||
{"start":"config.table"},
|
||||
{"widget":"Add or edit rule"},
|
||||
{"widget":"Choose…"},
|
||||
{"widget":"length"},
|
||||
{"widget":"Save Rule"}
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(h.app.session.doc.configs.rules.len(), 1);
|
||||
assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm");
|
||||
let before = h.app.session.doc.clone();
|
||||
h.run(&json!([{"widget":"Preview Row"}])).unwrap();
|
||||
assert_eq!(h.app.session.doc, before);
|
||||
h.run(&json!([{"widget":"Apply"}])).unwrap();
|
||||
assert_eq!(h.app.session.doc.param("length").unwrap().expr, "10 mm");
|
||||
assert!(h.app.session.doc.configuration_matches());
|
||||
h.run(&json!([
|
||||
{"widget":"10 mm","near":"Value"},{"key":"A","cmd":true},{"text":"20 mm"},{"widget":"Save Rule"},
|
||||
{"widget":"Apply"}
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(h.app.session.doc.param("length").unwrap().expr, "20 mm");
|
||||
assert_eq!(h.app.session.doc.configs.rows[0].cells[0], "40 mm");
|
||||
let before = h.app.session.doc.clone();
|
||||
h.run(&json!([
|
||||
{"widget":"Drive parameter"},{"widget":"Range"},
|
||||
{"widget":"0 mm","near":"Minimum"},{"key":"A","cmd":true},{"text":"100 mm"},{"widget":"Save Rule"}
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(h.app.session.doc, before, "failed rule edit is atomic");
|
||||
assert!(
|
||||
h.widget("configuration rules: rule `New rule` restricts the final value").is_some()
|
||||
|| h.output
|
||||
.accesskit_update
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|(_, n)| n.label().or(n.value()).is_some_and(|text| text.contains("restricts the final value")))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_component_and_face_pickers_start_fresh_and_clear_inherited() {
|
||||
for (component, width) in [(true, 1400), (false, 900)] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.resize", json!({"width":width,"height":720}));
|
||||
if component {
|
||||
h.call("engine.execute", json!({"command":"component.create","params":{"name":"Part"}}));
|
||||
}
|
||||
h.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"solid.box","params":{"length":10,"width":10,"height":10,"body_name":"Block"}}},
|
||||
{"start":"config.table"}, {"widget":"Add Column"}
|
||||
]))
|
||||
.unwrap();
|
||||
if component {
|
||||
h.step(&json!({"widget":"Part (component appearance)"})).unwrap();
|
||||
assert!(h.widget("Component: Part").is_some());
|
||||
} else {
|
||||
h.run(&json!([{"widget":"Face Appearance"},{"widget":"Faces on Block"},{"widget":"+z"}])).unwrap();
|
||||
assert!(h.widget("Face: Block / +z").is_some());
|
||||
}
|
||||
assert_eq!(h.app.session.doc.configs.rows.len(), 1);
|
||||
h.run(&json!([{"widget":"Library"},{"widget":"ABS - Red"},{"widget":"Apply"}])).unwrap();
|
||||
if component {
|
||||
assert_eq!(h.app.session.doc.appearances.components.values().next().unwrap().name, "ABS - Red");
|
||||
} else {
|
||||
assert_eq!(h.app.session.doc.appearances.faces[0].look.name, "ABS - Red");
|
||||
assert!(h.app.session.doc.appearances.faces[0].strict_name);
|
||||
}
|
||||
h.run(&json!([{"widget":"Library"},{"widget":"Follow inherited appearance"},{"widget":"Apply"}])).unwrap();
|
||||
assert!(h.app.session.doc.appearances.components.is_empty());
|
||||
assert!(h.app.session.doc.appearances.faces.is_empty());
|
||||
crate::scenario::check(&mut h, &json!({"windows_fit":{"max_width":560}})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_annulus_picker_exposes_both_certificates_despite_shared_legacy_name() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.resize", json!({"width":1400,"height":900}));
|
||||
h.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[0,0],"radius":10,"id":"outer"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[0,0],"radius":9.999,"id":"inner"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.finish","params":{}}},
|
||||
{"call":"engine.execute","params":{"command":"solid.extrude","params":{"distance":10,"profiles":[{"point":[9.9995,0]}],"body_name":"Ring"}}},
|
||||
{"start":"config.table"},{"widget":"Add Column"},{"widget":"Face Appearance"},{"widget":"Faces on Ring"}
|
||||
]))
|
||||
.unwrap();
|
||||
assert!(h.widget("side:outer").is_some());
|
||||
assert!(h.widget("side:inner").is_some());
|
||||
h.run(&json!([{"widget":"side:outer"},{"widget":"Library"},{"widget":"ABS - Red"},{"widget":"Apply"}])).unwrap();
|
||||
let solvecraft_engine::doc::config::Column::FaceAppearance(target) = &h.app.session.doc.configs.columns[0] else { panic!() };
|
||||
assert!(matches!(&target.certificate, Some(solvecraft_engine::doc::config::FaceCertificate::ExtrudeSide { curve, .. }) if curve == "outer"));
|
||||
let state = h.app.session.model.state();
|
||||
let body = state.body("Ring").unwrap();
|
||||
let indices = h.app.session.doc.configuration_face_indices(body, target).unwrap();
|
||||
assert_eq!(indices.len(), 2);
|
||||
let painted = h.app.session.doc.face_colors(body);
|
||||
assert_eq!(painted.len(), 2);
|
||||
assert!(painted.iter().all(|(index, _)| indices.contains(index)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_style_picker_applies_then_inherits_material() {
|
||||
let steps: Value = serde_json::from_str(include_str!("../tests/scenarios/s80_configuration_styles.json")).unwrap();
|
||||
let mut harness = crate::scenario::Harness::new();
|
||||
for step in steps.as_array().unwrap() {
|
||||
harness.step(step).unwrap();
|
||||
match step.get("shot").and_then(Value::as_str) {
|
||||
Some("configuration_red") => {
|
||||
let look = harness.app.session.doc.appearances.bodies.get("Block").unwrap();
|
||||
assert_eq!(look.color, [196, 40, 40]);
|
||||
assert!(harness.app.session.doc.configuration_matches());
|
||||
}
|
||||
Some("configuration_default") => {
|
||||
assert!(!harness.app.session.doc.appearances.bodies.contains_key("Block"));
|
||||
let state = harness.app.session.model.state();
|
||||
let body = state.body("Block").unwrap();
|
||||
let look = harness.app.session.doc.body_look("Block", body.feature).unwrap();
|
||||
assert_eq!(look.name, "Steel");
|
||||
assert_eq!(look.color, [158, 164, 172]);
|
||||
assert!(harness.app.session.doc.configuration_matches());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_table_actions_stay_on_one_line_at_normal_and_narrow_widths() {
|
||||
for width in [1400, 900] {
|
||||
let mut harness = crate::scenario::Harness::new();
|
||||
harness.call("ui.resize", json!({"width": width, "height": 720}));
|
||||
harness
|
||||
.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"solid.box","params":{"length":40,"width":20,"height":10,"body_name":"Block"}}},
|
||||
{"start":"config.table"}
|
||||
]))
|
||||
.unwrap();
|
||||
assert_single_line(&harness, "Configuration");
|
||||
for name in ["span", "thickness", "depth", "clearance"] {
|
||||
harness.call("engine.execute", json!({"command":"parameters.change","params":{"name":name,"expression":"10 mm"}}));
|
||||
}
|
||||
harness
|
||||
.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"config.configure","params":{
|
||||
"params":["span","thickness","depth","clearance"],"materials":["Block"],"appearances":["Block"]
|
||||
}}},
|
||||
{"call":"engine.execute","params":{"command":"config.row","params":{"name":"Default","values":{"param:span":"20 mm"}}}}
|
||||
]))
|
||||
.unwrap();
|
||||
harness.frames(4);
|
||||
for label in ["Configuration", "Active", "Apply", "Add Row", "Add Column"] {
|
||||
assert_single_line(&harness, label);
|
||||
}
|
||||
crate::scenario::check(&mut harness, &json!({"windows_fit":{"max_width":560}})).unwrap();
|
||||
for name in ["span", "thickness", "depth", "clearance"] {
|
||||
harness.call("engine.execute", json!({"command":"config.column","params":{"param":name,"delete":true}}));
|
||||
}
|
||||
harness.call("engine.execute", json!({"command":"config.column","params":{"appearance":"Block","delete":true}}));
|
||||
assert_single_line(&harness, "Material: Block");
|
||||
harness.call("engine.execute", json!({"command":"config.column","params":{"material":"Block","delete":true}}));
|
||||
harness.call("engine.execute", json!({"command":"config.column","params":{"feature":"Box1"}}));
|
||||
assert_single_line(&harness, "Suppress: Box1");
|
||||
}
|
||||
fn assert_single_line(harness: &crate::scenario::Harness, label: &str) {
|
||||
let tree = harness.output.accesskit_update.as_ref().unwrap();
|
||||
let bounds = tree
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|(_, n)| n.label() == Some(label) || n.value() == Some(label))
|
||||
.filter_map(|(_, n)| n.bounds())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
!bounds.is_empty(),
|
||||
"missing {label}; labels/values: {:?}",
|
||||
tree.nodes.iter().filter_map(|(_, n)| n.label().or(n.value())).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(bounds.iter().all(|b| b.y1 - b.y0 <= 24.0), "{label} wrapped: {bounds:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn study_keys_by_joint_id() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -360,7 +360,12 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
|
||||
}
|
||||
row_label(ui, "");
|
||||
if ui.button("Export DXF…").clicked() {
|
||||
*status = Some(export_dxf(app, v["body"].as_str().unwrap_or("")));
|
||||
*status = Some(export_flat(app, v["body"].as_str().unwrap_or(""), "dxf"));
|
||||
}
|
||||
ui.end_row();
|
||||
row_label(ui, "");
|
||||
if ui.button("Export Bend Table…").clicked() {
|
||||
*status = Some(export_flat(app, v["body"].as_str().unwrap_or(""), "csv"));
|
||||
}
|
||||
ui.end_row();
|
||||
if let Some(s) = status {
|
||||
@@ -381,12 +386,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
|
||||
enter
|
||||
}
|
||||
|
||||
fn export_dxf(app: &mut SolveApp, body: &str) -> String {
|
||||
let name = format!("{}-{body}.dxf", app.session.doc.name);
|
||||
let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&name, &["dxf"])) else {
|
||||
fn export_flat(app: &mut SolveApp, body: &str, extension: &str) -> String {
|
||||
let name = format!("{}-{body}.{extension}", app.session.doc.name);
|
||||
let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[extension])) else {
|
||||
return "Export cancelled".into();
|
||||
};
|
||||
match app.run("sheet.export_dxf", json!({"body": body, "path": path})) {
|
||||
let command = if extension == "csv" { "sheet.export_bend_table" } else { "sheet.export_dxf" };
|
||||
match app.run(command, json!({"body": body, "path": path})) {
|
||||
Ok(_) => {
|
||||
app.set_status(format!("Exported {path}"), false);
|
||||
format!("Saved {path}")
|
||||
@@ -708,9 +714,17 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool {
|
||||
if !(lo.0.is_finite() && hi.0 > lo.0 && hi.1 > lo.1) {
|
||||
return true;
|
||||
}
|
||||
// Fit with a margin, leaving the dialog's side free.
|
||||
let area = Rect::from_min_max(rect.min + vec2(40.0, 60.0), rect.max - vec2(320.0, 60.0));
|
||||
let area = if area.width() < 100.0 { rect.shrink(40.0) } else { area };
|
||||
// Fit all retained islands to the exposed viewport. The command panel can grow
|
||||
// to its width limit, so its actual edge, rather than a fixed reserve, bounds the view.
|
||||
let panel_left = painter
|
||||
.ctx()
|
||||
.memory(|m| m.area_rect(egui::Id::new(("sc_dialog", "FLAT PATTERN"))))
|
||||
.map(|panel| panel.left())
|
||||
.unwrap_or(rect.right() - crate::frame::DIALOG_MAX_W);
|
||||
let area = Rect::from_min_max(rect.min + vec2(40.0, 60.0), pos2(panel_left.min(rect.right()) - 20.0, rect.bottom() - 60.0));
|
||||
if area.width() <= 0.0 || area.height() <= 0.0 {
|
||||
return true;
|
||||
}
|
||||
let scale = (f64::from(area.width()) / (hi.0 - lo.0)).min(f64::from(area.height()) / (hi.1 - lo.1));
|
||||
let ox = f64::from(area.center().x) - (lo.0 + hi.0) * 0.5 * scale;
|
||||
let oy = f64::from(area.center().y) + (lo.1 + hi.1) * 0.5 * scale;
|
||||
@@ -727,6 +741,26 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool {
|
||||
painter.add(egui::Shape::closed_line(pts, Stroke::new(1.4, t.text)));
|
||||
}
|
||||
}
|
||||
// A pocket retains material below (or above) the cut. Dashed contours and a depth
|
||||
// annotation distinguish it from the solid through-cut boundaries.
|
||||
let pockets: Vec<&Value> = v["pockets"].as_array().into_iter().flatten().collect();
|
||||
for pocket in &pockets {
|
||||
let outer: Vec<(f64, f64)> = pocket["outline"].as_array().into_iter().flatten().filter_map(read).collect();
|
||||
for contour in std::iter::once(&pocket["outline"]).chain(pocket["cutouts"].as_array().into_iter().flatten()) {
|
||||
let mut pts: Vec<Pos2> = contour.as_array().into_iter().flatten().filter_map(read).map(to).collect();
|
||||
if pts.len() >= 3 {
|
||||
pts.push(pts[0]);
|
||||
painter.extend(egui::Shape::dashed_line(&pts, Stroke::new(1.6, t.accent), 3.0, 3.0));
|
||||
}
|
||||
}
|
||||
if !outer.is_empty() {
|
||||
let bounds = outer.iter().map(|p| to(*p)).fold(Rect::NOTHING, |r, p| r.union(Rect::from_min_max(p, p)));
|
||||
let anchor = bounds.center_top() - vec2(0.0, 6.0);
|
||||
if let Some(label) = pocket_label(pocket) {
|
||||
painter.text(anchor, Align2::CENTER_BOTTOM, label, FontId::proportional(12.0), t.accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
for b in v["bends"].as_array().into_iter().flatten() {
|
||||
let (Some(a), Some(e)) = (read(&b["start"]), read(&b["end"])) else { continue };
|
||||
let col = if b["direction"] == "up" { up_col } else { down_col };
|
||||
@@ -740,9 +774,33 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool {
|
||||
painter.line_segment([pos2(rect.left() + 14.0, y + 7.0), pos2(rect.left() + 38.0, y + 7.0)], Stroke::new(2.0, col));
|
||||
painter.text(pos2(rect.left() + 44.0, y), Align2::LEFT_TOP, label, FontId::proportional(12.0), t.text_dim);
|
||||
}
|
||||
if !pockets.is_empty() {
|
||||
y += 20.0;
|
||||
painter.extend(egui::Shape::dashed_line(
|
||||
&[pos2(rect.left() + 14.0, y + 7.0), pos2(rect.left() + 38.0, y + 7.0)],
|
||||
Stroke::new(1.6, t.accent),
|
||||
3.0,
|
||||
3.0,
|
||||
));
|
||||
painter.text(pos2(rect.left() + 44.0, y), Align2::LEFT_TOP, "Pocket boundary (material remains)", FontId::proportional(12.0), t.text_dim);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn pocket_label(pocket: &Value) -> Option<String> {
|
||||
let depth = pocket["depth_mm"].as_f64()?;
|
||||
if !depth.is_finite() || depth <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let side = match pocket["side"].as_str()? {
|
||||
"top" => "Top",
|
||||
"bottom" => "Bottom",
|
||||
"internal" => "Internal",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!("{side} pocket: {depth} mm deep"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "dialogs_sheet_tests.rs"]
|
||||
mod tests;
|
||||
@@ -106,3 +106,170 @@ fn hem_and_rules() {
|
||||
run_all(&mut app, c);
|
||||
assert!(app.session.doc.sheet.rules.iter().any(|r| r.name == "Alu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_exports_use_the_save_picker() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let mut app = plate();
|
||||
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
|
||||
let picks = Arc::new(Mutex::new(Vec::new()));
|
||||
let captured = picks.clone();
|
||||
app.services.pick_save = Some(Box::new(move |name, extensions| {
|
||||
captured.lock().unwrap().push((name.to_string(), extensions.iter().map(|x| x.to_string()).collect::<Vec<_>>()));
|
||||
None
|
||||
}));
|
||||
assert_eq!(export_flat(&mut app, "Base", "csv"), "Export cancelled");
|
||||
assert_eq!(export_flat(&mut app, "Base", "dxf"), "Export cancelled");
|
||||
let picks = picks.lock().unwrap();
|
||||
assert!(picks[0].0.ends_with("-Base.csv"));
|
||||
assert_eq!(picks[0].1, vec!["csv"]);
|
||||
assert!(picks[1].0.ends_with("-Base.dxf"));
|
||||
assert_eq!(picks[1].1, vec!["dxf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shallow_pocket_overlay_labels_depth_without_drawing_a_through_void() {
|
||||
let mut app = plate();
|
||||
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
|
||||
app.run("sketch.create", json!({"plane": "XY", "name": "Pocket"})).unwrap();
|
||||
app.run("sketch.rectangle.two_point", json!({"p0": [20, 20], "p1": [30, 30]})).unwrap();
|
||||
app.run("sketch.finish", json!({})).unwrap();
|
||||
app.run("solid.extrude", json!({"sketch": "Pocket", "distance": 1, "operation": "cut"})).unwrap();
|
||||
let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap();
|
||||
assert_eq!(flat["cutouts"], json!([]));
|
||||
assert_eq!(flat["pockets"][0]["depth_mm"], 1.0);
|
||||
app.start("sheet.flat_pattern");
|
||||
let mut dialog = app.dialog.clone().unwrap();
|
||||
let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") };
|
||||
*data = Some(Ok(flat));
|
||||
let ctx = egui::Context::default();
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("flat-pocket-test")));
|
||||
assert!(overlay(&painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let texts: Vec<&str> = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Text(text) => Some(text.galley.text()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(texts.contains(&"Bottom pocket: 1 mm deep"));
|
||||
assert!(texts.contains(&"Pocket boundary (material remains)"));
|
||||
let paths: Vec<_> = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Path(path) => Some(path),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!paths.is_empty(), "the retained sheet outline is drawn");
|
||||
assert!(paths.iter().all(|p| p.fill == Color32::TRANSPARENT), "pockets must not be rendered as filled voids");
|
||||
let lines = output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::LineSegment { .. })).count();
|
||||
assert!(lines > 6, "pocket contours and legend use dashed lines, not through-cut strokes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_pattern_islands_fit_before_the_actual_command_panel() {
|
||||
let mut app = plate();
|
||||
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
|
||||
app.run("sketch.create", json!({"plane": "XY", "name": "Retain"})).unwrap();
|
||||
for x in [20, 60] {
|
||||
app.run("sketch.rectangle.two_point", json!({"p0": [x, 20], "p1": [x + 10, 30]})).unwrap();
|
||||
}
|
||||
app.run("sketch.finish", json!({})).unwrap();
|
||||
app.run("solid.extrude", json!({"sketch": "Retain", "profiles": "all", "through_all": true, "direction": "symmetric", "operation": "intersect"}))
|
||||
.unwrap();
|
||||
let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap();
|
||||
assert_eq!(flat["outline"].as_array().unwrap().len(), 2);
|
||||
app.start("sheet.flat_pattern");
|
||||
let mut dialog = app.dialog.clone().unwrap();
|
||||
let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") };
|
||||
*data = Some(Ok(flat));
|
||||
let ctx = egui::Context::default();
|
||||
let id = egui::Id::new(("sc_dialog", "FLAT PATTERN"));
|
||||
let mut output = egui::FullOutput::default();
|
||||
for _ in 0..3 {
|
||||
output = ctx.run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), ..Default::default() }, |ui| {
|
||||
crate::frame::docked("FLAT PATTERN", id, pos2(1400.0, 294.0), 540.0, crate::frame::Width::Normal).min_width(360.0).show(ui.ctx(), |ui| {
|
||||
ui.label("Flat size: 50 × 10 × 2.5 mm");
|
||||
});
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Background, egui::Id::new("island-fit")));
|
||||
assert!(overlay(&painter, Rect::from_min_max(pos2(250.0, 126.0), pos2(1400.0, 832.0)), &dialog));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
let panel = ctx.memory(|m| m.area_rect(id)).unwrap();
|
||||
let outlines: Vec<_> = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Path(p) if p.closed && p.fill == Color32::TRANSPARENT => Some(p),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(outlines.len(), 2, "both retained islands are drawn");
|
||||
assert!(
|
||||
outlines.iter().flat_map(|p| &p.points).all(|p| p.x <= panel.left() - 19.0),
|
||||
"both complete islands must remain exposed before the command panel: {panel:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pocket_export_button_uses_the_picker_and_preserves_depth_layers() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
for (command, params) in [
|
||||
("sketch.create", json!({"plane":"XY","name":"Base"})),
|
||||
("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[100,60]})),
|
||||
("sketch.finish", json!({})),
|
||||
("sheet.flange", json!({"sketch":"Base"})),
|
||||
("sketch.create", json!({"plane":"XY","name":"Pocket"})),
|
||||
("sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})),
|
||||
] {
|
||||
h.app.run(command, params).unwrap();
|
||||
}
|
||||
let path = h.dir.join("picked-pocket.dxf");
|
||||
let chosen = path.to_string_lossy().into_owned();
|
||||
h.app.services.pick_save = Some(Box::new(move |name, extensions| {
|
||||
assert!(name.ends_with("-Body1.dxf"));
|
||||
assert_eq!(extensions, &["dxf"]);
|
||||
Some(chosen.clone())
|
||||
}));
|
||||
h.app.start("sheet.flat_pattern");
|
||||
h.settle();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"widget":"Export DXF…"})).unwrap();
|
||||
let exported = std::fs::read_to_string(&path).expect("actual Export DXF button writes the picker destination");
|
||||
assert!(exported.contains("POCKET_BOTTOM_Z0.000000_TO_1.000000_MM"));
|
||||
assert!(exported.contains("OUTLINE"));
|
||||
assert_eq!(h.app.session.undo.len(), undo, "export does not alter the part");
|
||||
assert!(h.app.dialog.is_some(), "export keeps the flat viewer open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn bend_table_picker_writes_the_current_body() {
|
||||
let mut app = plate();
|
||||
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
|
||||
app.run("sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})).unwrap();
|
||||
let body = app.session.model.state().sheets[0].body.clone();
|
||||
let path = std::env::temp_dir().join(format!("solvecraft-bend-table-{}.csv", std::process::id()));
|
||||
let output = path.to_string_lossy().into_owned();
|
||||
let chosen = output.clone();
|
||||
app.services.pick_save = Some(Box::new(move |_, extensions| {
|
||||
assert_eq!(extensions, &["csv"]);
|
||||
Some(chosen.clone())
|
||||
}));
|
||||
let undo = app.session.undo.len();
|
||||
assert_eq!(export_flat(&mut app, &body, "csv"), format!("Saved {output}"));
|
||||
let contents = std::fs::read_to_string(&path).unwrap();
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert_eq!(contents.lines().count(), 2);
|
||||
assert!(contents.contains(",90.000000000,up,2.500000000,"), "{contents}");
|
||||
assert_eq!(app.session.undo.len(), undo);
|
||||
}
|
||||
@@ -81,6 +81,8 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
|
||||
app.cam_anim = None;
|
||||
app.preview = crate::preview::PreviewState::default();
|
||||
app.viewport.hover = None;
|
||||
app.menu.props = None;
|
||||
crate::sketch_attachment_ui::clear(app);
|
||||
let out_rev = app.session.revision;
|
||||
let out = DocSlot {
|
||||
session: std::mem::replace(&mut app.session, slot.session),
|
||||
@@ -140,6 +142,8 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
|
||||
let rev = app.session.revision;
|
||||
crate::dialogs::cancel(app);
|
||||
app.tool = None;
|
||||
app.menu.props = None;
|
||||
crate::sketch_attachment_ui::clear(app);
|
||||
app.session = session;
|
||||
app.session.invalidate_view(rev);
|
||||
app.cam = Camera::default();
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Exercise the actual feature editor, including frames rendered before OK.
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::doc::{Extent, FeatureKind};
|
||||
|
||||
fn make(params: Value) -> Harness {
|
||||
make_in(Harness::new(), params)
|
||||
}
|
||||
fn make_in(mut h: Harness, params: Value) -> Harness {
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let mut p = json!({"sketch":"Base","distance":"10 mm","operation":"new","name":"Depth","body_name":"Main"});
|
||||
p.as_object_mut().unwrap().extend(params.as_object().unwrap().clone());
|
||||
h.app.run("solid.extrude", p).unwrap();
|
||||
h.frames(4);
|
||||
h
|
||||
}
|
||||
fn extent(h: &Harness) -> Extent {
|
||||
let FeatureKind::Extrude { extent, .. } = &h.app.session.doc.find_feature("Depth").unwrap().kind else { panic!("extrude") };
|
||||
extent.clone()
|
||||
}
|
||||
fn open(h: &mut Harness) {
|
||||
h.step(&json!({"call":"ui.editFeature","params":{"feature":"Depth"}})).unwrap();
|
||||
h.frames(8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_all_profiles_includes_profiles_added_after_edit() {
|
||||
let mut h = make(json!({}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
let FeatureKind::Extrude { profiles, .. } = &h.app.session.doc.find_feature("Depth").unwrap().kind else { panic!("extrude") };
|
||||
assert!(matches!(profiles, solvecraft_engine::doc::ProfileSel::All));
|
||||
h.app.run("sketch.edit", json!({"sketch":"Base"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[20,0],"p1":[25,5]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let volume: f64 = state.bodies.iter().map(|b| solvecraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum();
|
||||
assert!((volume - 1250.0).abs() < 1e-7, "{volume}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_actual_editor_preserves_all_extents_and_parameter_identities() {
|
||||
for p in [
|
||||
json!({"direction":"negative","distance":"10"}),
|
||||
json!({"direction":"negative","distance":"-10 mm"}),
|
||||
json!({"direction":"symmetric","distance":"5 mm"}),
|
||||
json!({"distance":"10 mm","distance2":"3 mm"}),
|
||||
json!({"from":[0,0,5],"start_offset":"0 mm"}),
|
||||
json!({"to":[0,0,10],"to_offset":"3 mm"}),
|
||||
json!({"to":[0,0,-10],"to_offset":"-2 mm"}),
|
||||
json!({"taper":"0 deg"}),
|
||||
] {
|
||||
let mut h = make(p.clone());
|
||||
let original = extent(&h);
|
||||
let names = h.app.session.doc.find_feature("Depth").unwrap().param_names.clone();
|
||||
let before = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{p}: {:?}", h.app.session.log);
|
||||
assert_eq!(extent(&h), original, "{p}");
|
||||
assert_eq!(h.app.session.doc.find_feature("Depth").unwrap().param_names, names, "{p}");
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), before, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_edit_keeps_downstream_distance_reference_positive() {
|
||||
let mut h = make(json!({"direction":"negative","distance":"10"}));
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":"d1","corner":[30,0,0],"body_name":"Dependent"})).unwrap();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(h.app.session.doc.eval("d1", solvecraft_engine::doc::expr::Kind::Length).unwrap(), 10.0);
|
||||
assert_eq!(h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_after_canvas_edit_preserves_document_and_undo() {
|
||||
let mut h = make(json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"}));
|
||||
let original = extent(&h);
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
assert_eq!(crate::canvas::anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().0.z, 5.0);
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"4 mm"})).unwrap();
|
||||
h.frames(8);
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(extent(&h), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_offset_canvas_edit_changes_offset_and_one_undo_restores_extent() {
|
||||
let mut h = make(json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"}));
|
||||
let original = extent(&h);
|
||||
open(&mut h);
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"4 mm"})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h).to_offset.as_deref(), Some("4 mm"));
|
||||
assert_eq!(extent(&h).distance, original.distance);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 19.0);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(extent(&h), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_owner_references_survive_actual_edit_with_another_component_active() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make_in(h, json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"}));
|
||||
h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20]})).unwrap();
|
||||
let other = h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":other["occurrence"],"translate":[200,0,40]})).unwrap();
|
||||
let original = extent(&h);
|
||||
let active = h.app.session.active_component;
|
||||
open(&mut h);
|
||||
let base = crate::canvas::anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().0;
|
||||
assert_eq!(base.z, 25.0);
|
||||
assert!(base.x >= 100.0 && base.x <= 110.0);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h), original);
|
||||
assert_eq!(h.app.session.active_component, active);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 37.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragged_to_object_handle_edits_offset_not_dormant_distance() {
|
||||
let mut h = make(json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"}));
|
||||
open(&mut h);
|
||||
let original = extent(&h);
|
||||
h.step(&json!({"drag":[{"handle":"arrow"},{"world":[5,5,21]}],"ctrl":true})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
|
||||
assert_eq!(extent(&h).distance, original.distance);
|
||||
let state = h.app.session.world_state();
|
||||
let z = state.body("Main").unwrap().mesh().bounds().max.z;
|
||||
assert!((z - 21.0).abs() < 0.1, "dragged end {z}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn referenced_offset_recomputes_after_edit_and_save_reopen() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("parameters.add", json!({"name":"clearance","expression":"2 mm"})).unwrap();
|
||||
let mut h = make_in(h, json!({"to":[0,0,15],"to_offset":"clearance"}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h).to_offset.as_deref(), Some("clearance"));
|
||||
h.app.run("parameters.change", json!({"name":"clearance","expression":"6 mm"})).unwrap();
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 21.0);
|
||||
let path = h.dir.join("extent.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
assert_eq!(extent(&h).to_offset.as_deref(), Some("clearance"));
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 21.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_object_start_can_be_explicitly_changed_to_profile_plane() {
|
||||
let mut h = make(json!({"from":[0,0,5]}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"click":{"handle":"dialog:Start"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Start:Profile Plane"}})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(extent(&h).from.is_none());
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_reverse_keeps_original_expression_and_parameter_sign() {
|
||||
let mut h = make(json!({"distance":"10"}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"Reverse Direction"})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h).distance, "10");
|
||||
assert_eq!(extent(&h).direction, solvecraft_engine::doc::Direction::Negative);
|
||||
assert_eq!(h.app.session.doc.eval("d1", solvecraft_engine::doc::expr::Kind::Length).unwrap(), 10.0);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z, -10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_edit_and_cancel_keep_full_history_and_existing_redo() {
|
||||
let mut h = make(json!({}));
|
||||
for i in 0..205 {
|
||||
h.app.run("timeline.rename", json!({"feature":1,"name":format!("Base{i}")})).unwrap();
|
||||
}
|
||||
h.app.run("solid.box", json!({"length":2,"width":2,"height":2,"corner":[30,0,0],"body_name":"RedoBody"})).unwrap();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
let undo = h.app.session.undo.len();
|
||||
let redo = h.app.session.redo.len();
|
||||
assert_eq!(undo, 199);
|
||||
assert_eq!(redo, 1);
|
||||
h.app.session.selection = vec![solvecraft_engine::Sel::Body { name: "Main".into() }];
|
||||
let selection = h.app.session.selection.clone();
|
||||
let original = serde_json::to_value(&*h.app.session.doc).unwrap();
|
||||
open(&mut h);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.redo.len(), redo);
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"missing_extent"})).unwrap();
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_some(), "failed edit must remain editable");
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&*h.app.session.doc).unwrap(), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.redo.len(), redo);
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
h.app.run("edit.redo", json!({})).unwrap();
|
||||
assert!(h.app.session.world_state().body("RedoBody").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tapered_from_and_signed_to_have_correct_start_volume_and_preview_parity() {
|
||||
for (params, lo, hi) in [
|
||||
(json!({"from":[0,0,5],"distance":"10 mm","taper":"atan(0.1)"}), 5.0, 15.0),
|
||||
(json!({"from":[0,0,5],"to":[0,0,15],"direction":"negative","taper":"atan(0.1)"}), 5.0, 15.0),
|
||||
(json!({"from":[0,0,5],"to":[0,0,-5],"direction":"negative","taper":"atan(0.1)"}), -5.0, 5.0),
|
||||
(json!({"from":[0,0,5],"to":[0,0,-5],"direction":"positive","taper":"atan(0.1)"}), -5.0, 5.0),
|
||||
] {
|
||||
let mut h = make(params.clone());
|
||||
let state = h.app.session.world_state();
|
||||
let body = state.body("Main").unwrap();
|
||||
let bounds = body.mesh().bounds();
|
||||
assert!((bounds.min.z - lo).abs() < 1e-8 && (bounds.max.z - hi).abs() < 1e-8, "{params}: {bounds:?}");
|
||||
let expected = 10.0 * (100.0 + 120.0 + 144.0) / 3.0;
|
||||
let volume = solvecraft_engine::doc::kernel::measure(&body.body).unwrap().volume;
|
||||
assert!((volume - expected).abs() < 1e-5, "{params}: volume{volume} expected{expected}");
|
||||
let original = extent(&h);
|
||||
open(&mut h);
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview(&commands).unwrap();
|
||||
assert_eq!(preview.after.body("Main").unwrap().mesh().bounds(), bounds);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h), original);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_profiles_from_slanted_face_use_same_merged_centre_as_geometry() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":30,"width":30,"height":2,"corner":[-10,-10,10],"body_name":"Reference"})).unwrap();
|
||||
h.app.run("solid.move", json!({"bodies":["Reference"],"axis":[0,1,0],"angle":"atan(0.1)","origin":[0,0,10]})).unwrap();
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Adjacent"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[2,10]})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[10,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let angle = 0.1f64.atan();
|
||||
let point = [2.0 * angle.sin(), 0.0, 10.0 + 2.0 * angle.cos()];
|
||||
h.app.run("solid.extrude", json!({"sketch":"Adjacent","distance":10,"from":point,"name":"Depth","body_name":"Main","operation":"new"})).unwrap();
|
||||
let min_z = h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z;
|
||||
open(&mut h);
|
||||
let base = crate::canvas::anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().0;
|
||||
assert!((base.z - min_z).abs() < 1e-7, "displayed start{} geometry start{min_z}", base.z);
|
||||
// Averaging the two unmerged rectangle loops gives x=3.5; their internal
|
||||
// seam must not bias the reference ray used by the extent handle.
|
||||
let unmerged_height = 10.0 + 2.0 / angle.cos() - 3.5 * angle.tan();
|
||||
assert!((base.z - unmerged_height).abs() > 0.01, "fixture must distinguish the raw-profile centre");
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!((h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z - min_z).abs() < 1e-7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_second_occurrence_keeps_saved_from_and_to_points_exact() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make_in(h, json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"}));
|
||||
let second = h.app.run("occurrence.copy", json!({"component":owner["component"],"translate":[100,0,20]})).unwrap()["occurrence"].clone();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"axis":[1,0,0],"angle":"17 deg"})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":owner["component"],"occurrence":second,"path":[second]})).unwrap();
|
||||
let original = extent(&h);
|
||||
let before: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h), original);
|
||||
let after: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
|
||||
assert_eq!(after, before);
|
||||
}
|
||||
@@ -54,6 +54,8 @@ mod status_tests {
|
||||
assert!(app.session.log.last().is_some_and(|l| l.starts_with("Redo: ") && !l.contains("edit.redo")), "{:?}", app.session.log.last());
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod extrude_edit_tests;
|
||||
mod file_load;
|
||||
pub mod palette;
|
||||
pub mod params_dialog;
|
||||
@@ -63,10 +65,19 @@ mod preselect_tests;
|
||||
pub mod preview;
|
||||
pub mod recovery_ui;
|
||||
pub mod ref_images;
|
||||
#[cfg(test)]
|
||||
mod revolve_edit_tests;
|
||||
#[cfg(test)]
|
||||
mod revolve_manipulator_tests;
|
||||
pub mod scenario;
|
||||
pub mod selection;
|
||||
pub mod shortcut_box;
|
||||
pub mod sketch3d;
|
||||
#[cfg(test)]
|
||||
mod sketch3d_frame_ui_tests;
|
||||
pub(crate) mod sketch_attachment_ui;
|
||||
#[cfg(test)]
|
||||
mod sketch_attachment_ui_tests;
|
||||
pub mod sketch_dims;
|
||||
#[cfg(test)]
|
||||
mod sketch_edit_tests;
|
||||
@@ -404,7 +415,14 @@ impl SolveApp {
|
||||
match &r {
|
||||
Ok(_) => {
|
||||
self.session.echo(format!("{} done", command_label(id)));
|
||||
if matches!(id, "file.new" | "doc.open") {
|
||||
if matches!(id, "file.new" | "doc.open" | "doc.recover") {
|
||||
self.menu.props = None;
|
||||
sketch_attachment_ui::clear(self);
|
||||
// The old document is already replaced: discard its editor without
|
||||
// applying cancellation hooks (such as restoring its timeline marker).
|
||||
self.dialog = None;
|
||||
self.tool = None;
|
||||
self.preview = preview::PreviewState::default();
|
||||
self.cam = Camera::default();
|
||||
self.fit_view();
|
||||
}
|
||||
@@ -542,13 +560,13 @@ impl SolveApp {
|
||||
let Some(idx) = self.session.doc.feature_index(id) else { return };
|
||||
let marker = self.session.doc.marker;
|
||||
self.tool = None;
|
||||
if self.run("timeline.roll_to", json!({ "position": idx })).is_err() {
|
||||
if dialogs::roll_marker(self, Some(idx)).is_err() {
|
||||
return;
|
||||
}
|
||||
match dialogs::for_feature(self, id, marker) {
|
||||
Some(d) => self.dialog = Some(d),
|
||||
None => {
|
||||
let _ = self.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({})));
|
||||
let _ = dialogs::roll_marker(self, marker);
|
||||
self.palette.text = format!("timeline.edit {{\"feature\": {id}, \"set\": {{}}}}");
|
||||
self.ui.palette_open = true;
|
||||
}
|
||||
@@ -560,6 +578,13 @@ impl SolveApp {
|
||||
/// puts them back. Editing the sketch that is already open does nothing; editing another one
|
||||
/// finishes the current sketch first, so edit sessions never nest.
|
||||
pub fn edit_sketch(&mut self, id: u64) {
|
||||
if let solvecraft_engine::doc::FaceAttachmentStatus::Lost { message, .. } =
|
||||
self.session.placed_document().sketch_face_attachment(id, &self.session.model)
|
||||
{
|
||||
browser::redefine_sketch(self, id);
|
||||
self.set_status(message, true);
|
||||
return;
|
||||
}
|
||||
if self.session.active_sketch == Some(id) {
|
||||
return;
|
||||
}
|
||||
@@ -615,7 +640,7 @@ impl SolveApp {
|
||||
pub fn origin_visible(&self) -> bool {
|
||||
self.ui.show_origin
|
||||
|| (self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::Sketch))
|
||||
&& !self.session.world_state().bodies.iter().any(|b| !self.session.visibility.hidden_bodies.contains(&b.name)))
|
||||
&& !self.session.world_state().bodies.iter().any(|b| self.session.body_visible(&b.name)))
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, s: impl Into<String>, error: bool) {
|
||||
|
||||
@@ -13,6 +13,26 @@ fn sample_app() -> SolveApp {
|
||||
SolveApp::new(s, Services::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_leave_with_the_document_but_survive_a_failed_open() {
|
||||
let mut app = sample_app();
|
||||
app.menu.props = Some(("Prior body".into(), json!({"volume_mm3":42})));
|
||||
app.start("config.table");
|
||||
assert!(app.dialog.is_some());
|
||||
assert!(app.run("doc.open", json!({"path":"__missing_properties_regression__.solvecraft"})).is_err());
|
||||
assert!(app.menu.props.is_some());
|
||||
assert!(app.dialog.is_some());
|
||||
app.run("file.new", json!({"name":"New design"})).unwrap();
|
||||
assert!(app.menu.props.is_none());
|
||||
assert!(app.dialog.is_none());
|
||||
app.menu.props = Some(("New design".into(), json!({})));
|
||||
crate::documents::open_tab(&mut app, Session::default()).unwrap();
|
||||
assert!(app.menu.props.is_none());
|
||||
app.menu.props = Some(("Blank design".into(), json!({})));
|
||||
crate::documents::adopt(&mut app, Session::default());
|
||||
assert!(app.menu.props.is_none());
|
||||
}
|
||||
|
||||
fn find(items: &[Item], label: &str) -> Item {
|
||||
items
|
||||
.iter()
|
||||
@@ -45,6 +65,32 @@ fn empty_space_offers_view_items_and_greys_out_undo() {
|
||||
assert!(!app.ui.show_origin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_browser_action_targets_the_clicked_body() {
|
||||
let mut app = sample_app();
|
||||
let b = body(&app);
|
||||
app.run("select.clear", json!({})).unwrap();
|
||||
act(&mut app, &Target::Body { name: b.clone() }, "Appearance");
|
||||
let dialog = app.dialog.as_ref().unwrap();
|
||||
assert!(matches!(dialog.kind, crate::dialogs::Kind::Appearance(_)));
|
||||
assert_eq!(dialog.inputs[0].items, vec![solvecraft_engine::Sel::Body { name: b }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_component_action_assigns_an_inherited_look() {
|
||||
let mut app = sample_app();
|
||||
let created = app.run("component.create", json!({"name": "Painted part"})).unwrap();
|
||||
let id = created["component"].as_u64().unwrap();
|
||||
act(&mut app, &Target::Component { id }, "Appearance");
|
||||
let d = app.dialog.clone().unwrap();
|
||||
let commands = crate::dialogs::apply_commands(&app, &d).unwrap();
|
||||
assert_eq!(commands[0].1["components"], json!(["Painted part"]));
|
||||
for (id, p) in commands {
|
||||
app.run(&id, p).unwrap();
|
||||
}
|
||||
assert!(app.session.doc.appearances.components.contains_key(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_menu_hides_and_finds_the_body() {
|
||||
let mut app = sample_app();
|
||||
@@ -54,7 +100,7 @@ fn face_menu_hides_and_finds_the_body() {
|
||||
for l in ["Create Sketch", "Offset Plane", "Press Pull", "Extrude", "Measure", "Find in Browser", "Find in Timeline"] {
|
||||
assert!(find(&items, l).enabled, "{l}");
|
||||
}
|
||||
assert!(!find(&items, "Appearance").enabled);
|
||||
assert!(find(&items, "Appearance").enabled);
|
||||
context_menu::run_item(&mut app, &find(&items, "Find in Timeline"), pos2(0.0, 0.0));
|
||||
assert!(matches!(app.session.selection.as_slice(), [solvecraft_engine::Sel::Feature { .. }]));
|
||||
context_menu::run_item(&mut app, &find(&items, "Show/Hide"), pos2(0.0, 0.0));
|
||||
@@ -97,6 +143,24 @@ fn properties_measure_the_body() {
|
||||
assert!(v["total"]["mass_g"].as_f64().unwrap() > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_properties_measure_one_assembly_placement() {
|
||||
let mut session = Session::default();
|
||||
let component = session.execute("component.create", &json!({"name": "Subassembly"})).unwrap()["component"].as_u64().unwrap();
|
||||
session.execute("component.create", &json!({"name": "Block"})).unwrap();
|
||||
session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||
session.execute("occurrence.copy", &json!({"component": component, "translate": [100, 0, 0]})).unwrap();
|
||||
let mut app = SolveApp::new(session, Services::default());
|
||||
act(&mut app, &Target::Component { id: component }, "Properties");
|
||||
let (title, properties) = app.menu.props.as_ref().unwrap();
|
||||
assert_eq!(title, "Subassembly");
|
||||
assert_eq!(properties["body_count"], 1);
|
||||
assert!((properties["total"]["volume_mm3"].as_f64().unwrap() - 1000.0).abs() < 1e-6);
|
||||
assert!((properties["total"]["center_of_mass"][0].as_f64().unwrap() - 5.0).abs() < 1e-6);
|
||||
act(&mut app, &Target::Component { id: 0 }, "Properties");
|
||||
assert_eq!(app.menu.props.as_ref().unwrap().1["body_count"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_menu_hides_and_moves_to_another_plane() {
|
||||
let mut app = sample_app();
|
||||
@@ -245,10 +309,33 @@ fn delete_key_handles_every_kind_and_asks_about_dependents() {
|
||||
fn menu_delete_items_use_the_same_path() {
|
||||
let mut app = sample_app();
|
||||
let b = body(&app);
|
||||
app.run("component.from_bodies", json!({"bodies": [b]})).unwrap();
|
||||
let original_volume = solvecraft_engine::kernel::measure(&app.session.world_state().body(&b).unwrap().body).unwrap().volume;
|
||||
let created = app.run("component.from_bodies", json!({"bodies": [b]})).unwrap();
|
||||
let c = app.session.doc.components[0].id;
|
||||
let transfer = created["components"][0]["feature"].as_u64().unwrap();
|
||||
let transfer_name = app.session.doc.feature(transfer).unwrap().name.clone();
|
||||
let saved = app.session.doc.to_json();
|
||||
act(&mut app, &Target::Component { id: c }, "Delete");
|
||||
let confirmation = app.menu.confirm.as_ref().expect("recorded transfer history needs confirmation");
|
||||
assert!(confirmation.report["deleted"].as_array().unwrap().iter().any(|name| name.as_str() == Some(transfer_name.as_str())));
|
||||
assert!(crate::delete::confirm(&mut app, false));
|
||||
assert_eq!(app.session.doc.to_json(), saved, "cancel keeps component and transfer history");
|
||||
act(&mut app, &Target::Component { id: c }, "Delete");
|
||||
assert!(crate::delete::confirm(&mut app, true));
|
||||
assert!(app.session.doc.components.is_empty());
|
||||
assert!(app.session.doc.feature(transfer).is_none());
|
||||
let restored = app.session.world_state();
|
||||
assert_eq!(restored.bodies.len(), 1, "the original joined plate stays intact");
|
||||
let plate = restored.body(&b).unwrap();
|
||||
assert_eq!(plate.component, 0);
|
||||
assert!((solvecraft_engine::kernel::measure(&plate.body).unwrap().volume - original_volume).abs() < 1e-6);
|
||||
assert!(plate.mesh().contains(solvecraft_engine::geom::Vec3::new(40.0, 35.0, 12.0)));
|
||||
let mut reopened = solvecraft_engine::Session::new(solvecraft_engine::doc::Document::from_json(&app.session.doc.to_json()).unwrap());
|
||||
reopened.refresh();
|
||||
let reopened_state = reopened.world_state();
|
||||
assert_eq!(reopened_state.bodies.len(), 1);
|
||||
assert_eq!(reopened_state.body(&b).unwrap().component, 0);
|
||||
assert!((solvecraft_engine::kernel::measure(&reopened_state.body(&b).unwrap().body).unwrap().volume - original_volume).abs() < 1e-6);
|
||||
// A sketch from the browser: asks, since its features go too.
|
||||
let id = app.session.doc.features.iter().find(|f| f.name == "Base").unwrap().id;
|
||||
act(&mut app, &Target::Sketch { id }, "Delete");
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
use crate::context_menu::{self, Target};
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::Sel;
|
||||
|
||||
#[test]
|
||||
fn replaced_body_name_uses_evaluated_owner_in_browser_and_component_actions() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})).unwrap();
|
||||
let transferred = h.app.run("component.from_bodies", json!({"bodies":["Block"]})).unwrap();
|
||||
let owner = transferred["components"][0]["component"].as_u64().unwrap();
|
||||
assert_eq!(context_menu::component_bodies(&h.app, owner), ["Block"]);
|
||||
h.app.run("solid.remove", json!({"bodies":["Block"]})).unwrap();
|
||||
h.app.run("solid.box", json!({"corner":[30,0,0],"length":5,"width":5,"height":5,"body_name":"Block"})).unwrap();
|
||||
h.frames(2);
|
||||
let state = h.app.session.model.state();
|
||||
let body = state.body("Block").unwrap();
|
||||
assert_eq!(h.app.session.doc.model_body_component(body), 0);
|
||||
assert_eq!(context_menu::component_bodies(&h.app, 0), ["Block"]);
|
||||
assert!(context_menu::component_bodies(&h.app, owner).is_empty());
|
||||
assert_eq!(super::entries(&h.app, 0, "bodies").iter().map(|entry| entry.key.as_str()).collect::<Vec<_>>(), ["Block"]);
|
||||
assert!(super::entries(&h.app, owner, "bodies").is_empty());
|
||||
let bounds = h.app.session.world_state().body("Block").unwrap().mesh().bounds();
|
||||
assert!((bounds.min.x - 30.0).abs() < 1e-9);
|
||||
assert!((bounds.max.x - 35.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
fn fixture() -> (Harness, u64, u64, u64) {
|
||||
let mut h = Harness::new();
|
||||
let part = h.app.run("component.create", json!({"name":"Block"})).unwrap();
|
||||
let component = part["component"].as_u64().unwrap();
|
||||
let first = part["occurrence"].as_u64().unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
let second = h.app.run("occurrence.copy", json!({"occurrence":first,"translate":[50,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.frames(3);
|
||||
(h, component, first, second)
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness, path: &[u64], control: &str) {
|
||||
let point = h.call("ui.at", json!({"occurrence":path,"control":control}));
|
||||
assert_eq!(point["ok"], true, "{point}");
|
||||
let p = &point["result"];
|
||||
let result = h.call("ui.click", json!({"x":p[0],"y":p[1]}));
|
||||
assert_eq!(result["ok"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_second_row_selects_exact_path_and_menu_actions() {
|
||||
let (mut h, component, first, second) = fixture();
|
||||
click(&mut h, &[second], "row");
|
||||
assert_eq!(h.app.session.selection, vec![Sel::Occurrence { path: vec![second] }]);
|
||||
let bounds = crate::viewport::selection_bounds(&h.app).unwrap();
|
||||
assert!((bounds.center().x - 55.0).abs() < 1e-8);
|
||||
let target = Target::Occurrence { path: vec![second] };
|
||||
let items = context_menu::items(&h.app, &target);
|
||||
let independent = items.iter().find(|i| i.id == "component.paste_new").unwrap();
|
||||
assert_eq!(independent.label, "Paste New (Own Features)");
|
||||
assert_eq!(independent.params.as_ref().unwrap()["occurrence"], second);
|
||||
assert_eq!(independent.params.as_ref().unwrap()["path"], json!([second]));
|
||||
let properties = items.iter().find(|i| i.id == "ui.properties").unwrap();
|
||||
assert_eq!(properties.params.as_ref().unwrap()["occurrence"], second);
|
||||
context_menu::run_item(&mut h.app, properties, egui::Pos2::ZERO);
|
||||
assert!((h.app.menu.props.as_ref().unwrap().1["total"]["center_of_mass"][0].as_f64().unwrap() - 55.0).abs() < 1e-8);
|
||||
let movement = items.iter().find(|i| i.id == "ui.moveOccurrence").unwrap();
|
||||
context_menu::run_item(&mut h.app, movement, egui::Pos2::ZERO);
|
||||
assert_eq!(h.app.tree.occurrence_move.as_ref().unwrap().path, vec![second]);
|
||||
let ground = items.iter().find(|i| i.id == "occurrence.ground").unwrap();
|
||||
context_menu::run_item(&mut h.app, ground, egui::Pos2::ZERO);
|
||||
assert!(h.app.session.doc.occurrences.iter().find(|o| o.id == second).unwrap().grounded);
|
||||
assert!(!h.app.session.doc.occurrences.iter().find(|o| o.id == first).unwrap().grounded);
|
||||
h.app.tree.occurrence_move = None;
|
||||
click(&mut h, &[second], "activate");
|
||||
assert_eq!(h.app.session.active_component, component);
|
||||
assert_eq!(h.app.session.active_occurrence_path, Some(vec![second]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_eye_and_fold_are_independent_and_survive_rename() {
|
||||
let (mut h, component, first, second) = fixture();
|
||||
click(&mut h, &[second], "eye");
|
||||
assert!(h.app.session.occurrence_hidden(&[second]));
|
||||
assert!(h.app.session.occurrence_visible(&[first]));
|
||||
let second_body = h.app.session.occurrence_bodies(&[second], true).unwrap()[0].clone();
|
||||
assert!(!h.app.session.body_visible(&second_body));
|
||||
click(&mut h, &[second], "fold");
|
||||
assert!(h.app.tree.collapsed.contains(&format!("o:{second}")));
|
||||
assert!(!h.app.tree.collapsed.contains(&format!("o:{first}")));
|
||||
h.app.run("component.rename", json!({"component":component,"name":"Renamed Block"})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(h.app.session.occurrence_hidden(&[second]));
|
||||
click(&mut h, &[second], "eye");
|
||||
assert!(h.app.session.occurrence_visible(&[second]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_move_dialog_keeps_other_pending_moves_and_reuses_initial_baseline() {
|
||||
let (mut h, _, first, second) = fixture();
|
||||
h.app.run("occurrence.move", json!({"occurrence":first,"translate":[0,10,0],"capture":false})).unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false})).unwrap();
|
||||
let first_pending = h.app.session.pending_moves[&first];
|
||||
let items = context_menu::items(&h.app, &Target::Occurrence { path: vec![second] });
|
||||
let movement = items.iter().find(|i| i.id == "ui.moveOccurrence").unwrap();
|
||||
context_menu::run_item(&mut h.app, movement, egui::Pos2::ZERO);
|
||||
let mut m = h.app.tree.occurrence_move.clone().unwrap();
|
||||
m.pivot =
|
||||
Some(solvecraft_engine::doc::apply_point(&h.app.session.occurrence_placement(&[second]).unwrap().world, solvecraft_engine::geom::Vec3::ZERO));
|
||||
m.translate = [10.0, 0.0, 0.0];
|
||||
crate::browser::apply_occurrence_move(&mut h.app, &m);
|
||||
let at = h.app.session.occurrence_placement(&[second]).unwrap();
|
||||
assert!((solvecraft_engine::doc::apply_point(&at.world, solvecraft_engine::geom::Vec3::ZERO).x - 65.0).abs() < 1e-8);
|
||||
m.translate = [3.0, 0.0, 0.0];
|
||||
crate::browser::apply_occurrence_move(&mut h.app, &m);
|
||||
let at = h.app.session.occurrence_placement(&[second]).unwrap();
|
||||
assert!((solvecraft_engine::doc::apply_point(&at.world, solvecraft_engine::geom::Vec3::ZERO).x - 58.0).abs() < 1e-8);
|
||||
assert_eq!(h.app.session.pending_moves[&first], first_pending);
|
||||
crate::browser::revert_occurrence_dialog(&mut h.app, &m);
|
||||
let restored = h.app.session.occurrence_placement(&[second]).unwrap();
|
||||
assert!((solvecraft_engine::doc::apply_point(&restored.world, solvecraft_engine::geom::Vec3::ZERO).x - 55.0).abs() < 1e-8);
|
||||
assert_eq!(h.app.session.pending_moves[&first], first_pending);
|
||||
h.app.run("component.revert_position", json!({"occurrences":[second]})).unwrap();
|
||||
context_menu::run_item(&mut h.app, movement, egui::Pos2::ZERO);
|
||||
let mut fresh = h.app.tree.occurrence_move.clone().unwrap();
|
||||
assert!(!fresh.had_pending);
|
||||
fresh.translate = [2.0, 0.0, 0.0];
|
||||
crate::browser::apply_occurrence_move(&mut h.app, &fresh);
|
||||
crate::browser::revert_occurrence_dialog(&mut h.app, &fresh);
|
||||
assert!(!h.app.session.pending_moves.contains_key(&second));
|
||||
assert_eq!(h.app.session.pending_moves[&first], first_pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_delete_targets_second_copy_even_if_first_was_selected() {
|
||||
let (mut h, _, first, second) = fixture();
|
||||
click(&mut h, &[first], "row");
|
||||
let items = context_menu::items(&h.app, &Target::Occurrence { path: vec![second] });
|
||||
let delete = items.iter().find(|i| i.id == "ui.delete").unwrap();
|
||||
context_menu::run_item(&mut h.app, delete, egui::Pos2::ZERO);
|
||||
crate::delete::confirm(&mut h.app, true);
|
||||
assert!(h.app.session.doc.occurrences.iter().any(|o| o.id == first));
|
||||
assert!(!h.app.session.doc.occurrences.iter().any(|o| o.id == second));
|
||||
assert_eq!(h.app.session.world_state().bodies.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_new_component_keeps_inactive_second_parent_authoring_context() {
|
||||
let (mut h, _, first, second) = fixture();
|
||||
assert_eq!(h.app.session.active_component, 0);
|
||||
let items = context_menu::items(&h.app, &Target::Occurrence { path: vec![second] });
|
||||
let create = items.iter().find(|i| i.id == "component.create").unwrap();
|
||||
assert_eq!(create.params.as_ref().unwrap()["parent_path"], json!([second]));
|
||||
context_menu::run_item(&mut h.app, create, egui::Pos2::ZERO);
|
||||
let active = h.app.session.active_occurrence_path.as_ref().unwrap();
|
||||
assert_eq!(active.len(), 2);
|
||||
assert_eq!(active[0], second);
|
||||
assert_ne!(active[0], first);
|
||||
h.app.run("solid.box", json!({"corner":[51,1,1],"length":2,"width":2,"height":2})).unwrap();
|
||||
let feature = h.app.session.model.state().bodies.last().unwrap().feature;
|
||||
let mut centers: Vec<f64> =
|
||||
h.app.session.world_state().bodies.iter().filter(|b| b.feature == feature).map(|b| b.mesh().bounds().center().x).collect();
|
||||
centers.sort_by(f64::total_cmp);
|
||||
assert_eq!(centers.len(), 2);
|
||||
assert!((centers[0] - 2.0).abs() < 1e-8 && (centers[1] - 52.0).abs() < 1e-8, "{centers:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_body_appearance_editor_inherits_definition_precision() {
|
||||
let (mut h, _, _, second) = fixture();
|
||||
h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":"#123456","appearance":"Tinted source","opacity":0.123456})).unwrap();
|
||||
let name = h.app.session.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
h.app.session.selection = vec![Sel::Body { name }];
|
||||
let (kind, _) = crate::dialogs_appearance::start(&h.app, "appearance.assign").unwrap();
|
||||
let crate::dialogs::Kind::Appearance(look) = kind else { panic!("appearance editor") };
|
||||
assert_eq!(look.color, [0x12, 0x34, 0x56]);
|
||||
assert_eq!(look.opacity, 0.123456);
|
||||
assert_eq!(look.look.as_deref(), Some("Tinted source"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_nested_shared_child_has_distinct_rows_and_scope() {
|
||||
let mut h = Harness::new();
|
||||
let parent = h.app.run("component.create", json!({"name":"Assembly"})).unwrap();
|
||||
let parent_component = parent["component"].as_u64().unwrap();
|
||||
let parent_occurrence = parent["occurrence"].as_u64().unwrap();
|
||||
let child = h.app.run("component.create", json!({"name":"Child"})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
let other = h.app.run("occurrence.copy", json!({"component":parent_component,"translate":[80,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.frames(3);
|
||||
click(&mut h, &[other, child], "row");
|
||||
assert_eq!(h.app.session.selection, vec![Sel::Occurrence { path: vec![other, child] }]);
|
||||
assert!((crate::viewport::selection_bounds(&h.app).unwrap().center().x - 85.0).abs() < 1e-8);
|
||||
let items = context_menu::items(&h.app, &Target::Occurrence { path: vec![other, child] });
|
||||
assert!(items.iter().any(|i| i.label.starts_with("Shared child:")));
|
||||
click(&mut h, &[other, child], "fold");
|
||||
assert!(h.app.tree.collapsed.contains(&format!("o:{other}/{child}")));
|
||||
assert!(!h.app.tree.collapsed.contains(&format!("o:{parent_occurrence}/{child}")));
|
||||
click(&mut h, &[other, child], "eye");
|
||||
assert!(h.app.session.occurrence_visible(&[parent_occurrence, child]));
|
||||
assert!(!h.app.session.occurrence_visible(&[other, child]));
|
||||
let delete = items.iter().find(|i| i.id == "ui.delete").unwrap();
|
||||
context_menu::run_item(&mut h.app, delete, egui::Pos2::ZERO);
|
||||
assert!(h.app.menu.confirm.is_some(), "shared child deletion must communicate scope before removing both renderings");
|
||||
}
|
||||
@@ -8,12 +8,239 @@ use solvecraft_engine::{Sel, Session};
|
||||
use crate::dialogs::{Dialog, Kind, apply_commands};
|
||||
use crate::{Services, SolveApp};
|
||||
|
||||
#[test]
|
||||
fn cylinder_emboss_empty_target_face_applies_automatic_wrap_and_deboss() {
|
||||
for deboss in [false, true] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap();
|
||||
let initial = h.app.session.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
let sketch =
|
||||
h.app.run("sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})).unwrap()["sketch"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.session.selection = vec![Sel::Profile { sketch, index: 0 }];
|
||||
h.app.start("solid.emboss");
|
||||
h.frames(2);
|
||||
if deboss {
|
||||
h.settle();
|
||||
h.step(&json!({"click":{"handle":"dialog:Type"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Type:Deboss"}})).unwrap();
|
||||
}
|
||||
let dialog = h.app.dialog.as_ref().unwrap();
|
||||
assert!(dialog.inputs[1].items.is_empty());
|
||||
let commands = apply_commands(&h.app, dialog).unwrap();
|
||||
assert!(commands[0].1.get("face").is_none());
|
||||
let preview = h.app.session.preview_world(&commands).unwrap();
|
||||
let expected = if deboss { -19.0 } else { 21.0 };
|
||||
let preview_delta = preview.after.body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume - initial;
|
||||
assert!(
|
||||
(preview_delta - expected).abs() < 0.05,
|
||||
"deboss={deboss}, preview delta={preview_delta}, expected={expected}, commands={commands:?}"
|
||||
);
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
|
||||
let feature = h.app.session.doc.features.last().unwrap().clone();
|
||||
let solvecraft_engine::doc::FeatureKind::Emboss { face: Some(face), deboss: actual, .. } = &feature.kind else {
|
||||
panic!("automatic Apply must save its cylinder anchor: {:?}", feature.kind)
|
||||
};
|
||||
assert_eq!(*actual, deboss);
|
||||
assert_eq!(face.body, "Sleeve");
|
||||
assert!(
|
||||
(h.app.session.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume - initial - expected)
|
||||
.abs()
|
||||
< 0.05
|
||||
);
|
||||
h.app.edit_feature(feature.id);
|
||||
h.frames(2);
|
||||
assert!(matches!(h.app.dialog.as_ref().unwrap().inputs[1].items.first(), Some(Sel::Face { body, .. }) if body == "Sleeve"));
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert_eq!(h.app.session.doc.feature(feature.id).unwrap().kind, feature.kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_emboss_target_face_and_edit_keep_the_anchored_operation() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap();
|
||||
let sketch = h.app.run("sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})).unwrap()["sketch"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.session.selection = vec![Sel::Profile { sketch, index: 0 }];
|
||||
h.app.start("solid.emboss");
|
||||
h.frames(2);
|
||||
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 1);
|
||||
let planar = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert!(planar[0].1.get("face").is_none());
|
||||
h.call("ui.dialogInput", json!({"index":1}));
|
||||
h.call("ui.view", json!({"view":"back"}));
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"world":[0,10,7.5]}})).unwrap();
|
||||
let dialog = h.app.dialog.as_ref().unwrap();
|
||||
assert!(matches!(dialog.inputs[1].items.first(), Some(Sel::Face { body, .. }) if body == "Sleeve"));
|
||||
let commands = apply_commands(&h.app, dialog).unwrap();
|
||||
assert_eq!(commands[0].1["targets"], json!(["Sleeve"]));
|
||||
assert!(commands[0].1.get("face").is_some());
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
|
||||
let feature = h.app.session.doc.features.last().unwrap().id;
|
||||
let before = serde_json::to_value(&h.app.session.doc.feature(feature).unwrap().kind).unwrap();
|
||||
h.app.edit_feature(feature);
|
||||
h.frames(2);
|
||||
let edited = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(edited[0].0, "timeline.redefine");
|
||||
assert_eq!(edited[0].1["params"]["targets"], json!(["Sleeve"]));
|
||||
assert!(edited[0].1["params"].get("face").is_some());
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.feature(feature).unwrap().kind).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_emboss_edit_retains_second_source_and_author_paths_and_all_profiles() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let source = h.app.run("component.create", json!({"name":"Source"})).unwrap()["component"].as_u64().unwrap();
|
||||
h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]})).unwrap();
|
||||
let source_second = h.app.run("occurrence.copy", json!({"component":source,"translate":[80,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.app.run("component.activate", json!({"component":"root"})).unwrap();
|
||||
let author = h.app.run("component.create", json!({"name":"Author"})).unwrap()["component"].as_u64().unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":"Author:1","axis":[1,0,0],"angle":"90 deg","translate":[-100,40,30]})).unwrap();
|
||||
let author_second = h.app.run("occurrence.copy", json!({"component":author,"translate":[0,100,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.app.run("component.activate", json!({"component":author,"occurrence":author_second})).unwrap();
|
||||
h.app.run("sketch.create", json!({"plane":{"origin":[120,0,0],"x_dir":[0,-1,0],"y_dir":[0,0,1]},"name":"Badge"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let target = h.app.session.occurrence_bodies(&[source_second], false).unwrap().into_iter().next().unwrap();
|
||||
let feature = h.app.run("solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[120,0,7.5],"depth":"1mm","targets":[target]})).unwrap()
|
||||
["feature"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let before = h.app.session.doc.feature(feature).unwrap().clone();
|
||||
let solvecraft_engine::doc::FeatureKind::Emboss { face: Some(face), .. } = &before.kind else { panic!("selected face") };
|
||||
assert_eq!(face.path, vec![source_second]);
|
||||
assert_eq!(face.author_path, vec![author_second]);
|
||||
h.app.run("component.activate", json!({"component":"root"})).unwrap();
|
||||
h.app.edit_feature(feature);
|
||||
h.frames(2);
|
||||
let dialog = h.app.dialog.as_ref().unwrap();
|
||||
let Sel::Face { body, point, .. } = &dialog.inputs[1].items[0] else { panic!("refilled face") };
|
||||
assert_eq!(body, &target);
|
||||
assert!(point.dist(Vec3::new(120.0, 0.0, 7.5)) < 1e-8);
|
||||
let commands = apply_commands(&h.app, dialog).unwrap();
|
||||
assert_eq!(commands[0].1["params"]["profiles"], json!("all"));
|
||||
let Sel::Profile { sketch, index } = dialog.inputs[0].items[0] else { panic!("refilled profile") };
|
||||
let plane = crate::dialogs::emboss_profile_plane(&h.app, sketch).unwrap().unwrap();
|
||||
assert!(plane.origin.dist(Vec3::new(120.0, 0.0, 0.0)) < 1e-8, "{plane:?}");
|
||||
let mut guide = crate::gpu::GpuScene::default();
|
||||
crate::viewport::profile_fill(&mut guide, &h.app, sketch, index, egui::Color32::BLUE, egui::Color32::BLUE);
|
||||
let (vertices, remainder) = guide.trans.as_chunks::<{ crate::gpu::TRI_SIZE }>();
|
||||
assert!(remainder.is_empty() && !vertices.is_empty());
|
||||
let lift = h.app.cam.back() * (h.app.cam.half_height() * 0.002);
|
||||
for vertex in vertices {
|
||||
let coordinate = |offset| f32::from_le_bytes(vertex[offset..offset + 4].try_into().unwrap()) as f64;
|
||||
let point = Vec3::new(coordinate(0), coordinate(4), coordinate(8)) - lift;
|
||||
assert!((point.x - 120.0).abs() < 1e-4 && point.y.abs() < 2.01 && (4.99..=10.01).contains(&point.z), "{point:?}");
|
||||
}
|
||||
let preview = h.app.session.preview_world(&commands).unwrap();
|
||||
let volume = preview.after.body(&target).unwrap().mesh().measure().volume;
|
||||
assert!(volume > preview.before.body(&target).unwrap().mesh().measure().volume + 10.0);
|
||||
let colors = crate::preview::Colors::from_tokens(&crate::theme::Tokens::get());
|
||||
let built = crate::preview::compute(&h.app.session, &commands, colors).unwrap();
|
||||
assert!(built.replaced.contains(&target) && !built.scene.tris.is_empty(), "{:?}", built.replaced);
|
||||
let source_first = h.app.session.doc.occurrence_of(source).unwrap().id;
|
||||
h.app.run("browser.visibility", json!({"paths":[[source_first]],"visible":false})).unwrap();
|
||||
let hidden = crate::preview::compute(&h.app.session, &commands, colors).unwrap();
|
||||
assert_eq!(hidden.replaced, vec![target.clone()]);
|
||||
let visible = h.app.session.preview_visible_world(&commands).unwrap();
|
||||
assert!(visible.before.body("Sleeve").is_none() && visible.after.body("Sleeve").is_none());
|
||||
assert!(visible.after.body(&target).is_some() && !hidden.scene.tris.is_empty());
|
||||
let mut shared = h.app.session.scratch();
|
||||
shared.execute("component.activate", &json!({"component":source,"occurrence":source_first})).unwrap();
|
||||
let new_body = shared
|
||||
.preview_visible_world(&[("solid.box".into(), json!({"body_name":"NewPreview","length":2,"width":2,"height":2,"operation":"new"}))])
|
||||
.unwrap();
|
||||
let added = new_body.after.bodies.iter().filter(|body| new_body.before.body(&body.name).is_none()).collect::<Vec<_>>();
|
||||
assert_eq!(added.len(), 1, "{:?}", added.iter().map(|body| &body.name).collect::<Vec<_>>());
|
||||
assert!(added[0].name.contains("Source:2"), "{}", added[0].name);
|
||||
let doc = h.app.session.doc.to_json();
|
||||
let history = (h.app.session.undo.len(), h.app.session.redo.len());
|
||||
let selection = h.app.session.selection.clone();
|
||||
let Kind::Emboss { depth, .. } = &mut h.app.dialog.as_mut().unwrap().kind else { panic!("Emboss editor") };
|
||||
*depth = "2 mm".into();
|
||||
let changed = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let changed_preview = h.app.session.preview_world(&changed).unwrap();
|
||||
assert!(changed_preview.after.body(&target).unwrap().mesh().measure().volume > volume + 10.0);
|
||||
let mut invalid = changed.clone();
|
||||
invalid[0].1["params"]["depth"] = json!("unknown_depth");
|
||||
assert!(h.app.session.preview_world(&invalid).is_err());
|
||||
assert_eq!(h.app.session.doc.to_json(), doc);
|
||||
assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), history);
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.dialog.is_none());
|
||||
assert_eq!(h.app.session.doc.feature(feature).unwrap().kind, before.kind);
|
||||
h.app.edit_feature(feature);
|
||||
h.frames(2);
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
|
||||
let after = h.app.session.doc.feature(feature).unwrap();
|
||||
assert_eq!(after.kind, before.kind);
|
||||
assert_eq!(after.face_names, before.face_names);
|
||||
assert_eq!(h.app.session.active_component, 0);
|
||||
}
|
||||
|
||||
fn app_with(script: serde_json::Value) -> SolveApp {
|
||||
let mut s = Session::default();
|
||||
s.run_script(&script).unwrap();
|
||||
SolveApp::new(s, Services::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_emboss_profile_guide_uses_target_or_unique_foreign_sketch_frame() {
|
||||
for target_owned in [true, false] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let source = if target_owned { h.app.run("component.create", json!({"name":"Source"})).unwrap()["component"].as_u64().unwrap() } else { 0 };
|
||||
h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap();
|
||||
let (profile_owner, expected, target, face) = if target_owned {
|
||||
h.app.run("occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]})).unwrap();
|
||||
let second = h.app.run("occurrence.copy", json!({"component":source,"translate":[80,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
let target = h.app.session.occurrence_bodies(&[second], false).unwrap().remove(0);
|
||||
(source, Vec3::new(120.0, 0.0, 0.0), target, json!([120, 0, 7.5]))
|
||||
} else {
|
||||
let profile = h.app.run("component.create", json!({"name":"Profile"})).unwrap()["component"].as_u64().unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":"Profile:1","axis":[0,0,1],"angle":"90 deg","translate":[50,20,0]})).unwrap();
|
||||
(profile, Vec3::new(0.0, 10.0, 0.0), "Sleeve".to_string(), json!([0, 10, 7.5]))
|
||||
};
|
||||
let plane = if target_owned {
|
||||
json!({"origin":[40,0,0],"x_dir":[0,-1,0],"y_dir":[0,0,1]})
|
||||
} else {
|
||||
json!({"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]})
|
||||
};
|
||||
let sketch = h.app.run("sketch.create", json!({"plane":plane,"name":"Badge"})).unwrap()["sketch"].as_u64().unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":"root"})).unwrap();
|
||||
let feature =
|
||||
h.app.run("solid.emboss", json!({"sketch":sketch,"profiles":"all","face":face,"depth":"1mm","targets":[target]})).unwrap()["feature"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
h.app.edit_feature(feature);
|
||||
h.frames(2);
|
||||
assert!(crate::dialogs::emboss_profile_plane(&h.app, sketch).unwrap().unwrap().origin.dist(expected) < 1e-8);
|
||||
if !target_owned {
|
||||
h.app.run("occurrence.copy", json!({"component":profile_owner,"translate":[100,0,0]})).unwrap();
|
||||
assert!(crate::dialogs::emboss_profile_plane(&h.app, sketch).unwrap_err().contains("ambiguous"));
|
||||
let mut guide = crate::gpu::GpuScene::default();
|
||||
crate::viewport::profile_fill(&mut guide, &h.app, sketch, 0, egui::Color32::BLUE, egui::Color32::BLUE);
|
||||
assert!(guide.trans.is_empty() && guide.lines.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn boxed() -> SolveApp {
|
||||
app_with(json!([{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}]))
|
||||
}
|
||||
|
||||
@@ -103,6 +103,28 @@ impl PreviewState {
|
||||
Err(e) => self.error = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors validate only the current inputs. Keep the last good geometry while
|
||||
/// computing new inputs, but never let an obsolete result block their Apply.
|
||||
fn poll(&mut self, key: u64) {
|
||||
if self.shown_key != Some(key) {
|
||||
self.error = None;
|
||||
}
|
||||
if let Some((k, rx)) = &self.running {
|
||||
let k = *k;
|
||||
let job = match rx.try_recv() {
|
||||
Ok(job) => Some(job),
|
||||
Err(TryRecvError::Empty) => None,
|
||||
Err(TryRecvError::Disconnected) => Some(Err("the preview failed".into())),
|
||||
};
|
||||
if let Some(job) = job {
|
||||
self.running = None;
|
||||
if k == key {
|
||||
self.apply(k, job);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the preview in step with the open dialog: start a computation when its inputs changed,
|
||||
@@ -110,6 +132,7 @@ impl PreviewState {
|
||||
pub fn update(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let cmds = match app.dialog.as_ref().filter(|d| d.previews()).map(|d| crate::dialogs::preview_commands(app, d)) {
|
||||
Some(Ok(c)) if !c.is_empty() => c,
|
||||
_ if crate::sketch_attachment_ui::commands(app).is_some() => crate::sketch_attachment_ui::commands(app).unwrap_or_default(),
|
||||
// A parameter being edited in Change Parameters.
|
||||
_ if app.params.as_ref().and_then(|p| p.preview_commands()).is_some() => {
|
||||
app.params.as_ref().and_then(|p| p.preview_commands()).unwrap_or_default()
|
||||
@@ -127,20 +150,7 @@ pub fn update(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
format!("{colors:?}").hash(&mut h);
|
||||
let key = h.finish();
|
||||
let pv = &mut app.preview;
|
||||
if let Some((k, rx)) = &pv.running {
|
||||
let k = *k;
|
||||
match rx.try_recv() {
|
||||
Ok(job) => {
|
||||
pv.running = None;
|
||||
pv.apply(k, job);
|
||||
}
|
||||
Err(TryRecvError::Empty) => {}
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
pv.running = None;
|
||||
pv.apply(k, Err("the preview failed".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
pv.poll(key);
|
||||
if pv.running.is_none() && pv.shown_key != Some(key) {
|
||||
pv.started = crate::now_ms();
|
||||
let scratch = app.session.scratch();
|
||||
@@ -176,6 +186,15 @@ fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, V
|
||||
/// 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 {
|
||||
if cmds.iter().any(|(command, _)| command == "sketch.redefine") {
|
||||
let preview = s.preview_visible_world(cmds).map_err(|error| error.to_string())?;
|
||||
return std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let mut built = build(&preview.before, &preview.after, colors);
|
||||
built.bounds = state_bounds(&preview.after);
|
||||
built
|
||||
}))
|
||||
.map_err(|_| "the preview failed".to_string());
|
||||
}
|
||||
// Joints and drives move occurrences: shown on the placed model.
|
||||
if let Some(job) = crate::dialogs_assembly::world_preview(s, cmds, colors) {
|
||||
return job;
|
||||
@@ -367,6 +386,41 @@ mod tests {
|
||||
Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correcting_inputs_clears_old_errors_and_discards_late_old_jobs() {
|
||||
let mut pv = PreviewState {
|
||||
shown_key: Some(1),
|
||||
error: Some("unknown_angle".into()),
|
||||
active: true,
|
||||
replaced: vec!["Turn".into()],
|
||||
key: 9,
|
||||
..PreviewState::default()
|
||||
};
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
pv.running = Some((1, rx));
|
||||
pv.poll(2);
|
||||
assert!(pv.error.is_none(), "the corrected inputs can Apply while their preview computes");
|
||||
assert!(pv.running.is_some());
|
||||
assert!(pv.active);
|
||||
assert_eq!(pv.replaced, ["Turn"]);
|
||||
assert_eq!(pv.key, 9, "the last good geometry stays visible");
|
||||
tx.send(Err("unknown_angle".into())).unwrap();
|
||||
pv.poll(2);
|
||||
assert!(pv.error.is_none(), "a late obsolete result cannot disable Apply again");
|
||||
assert!(pv.running.is_none());
|
||||
assert_eq!(pv.shown_key, Some(1));
|
||||
assert_eq!(pv.key, 9);
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
pv.running = Some((2, rx));
|
||||
tx.send(Err("current invalid expression".into())).unwrap();
|
||||
pv.poll(2);
|
||||
assert_eq!(pv.error.as_deref(), Some("current invalid expression"));
|
||||
assert_eq!(pv.shown_key, Some(2));
|
||||
pv.poll(2);
|
||||
assert_eq!(pv.error.as_deref(), Some("current invalid expression"), "current errors still prevent invalid Apply");
|
||||
}
|
||||
|
||||
fn count(bytes: &[u8], stride: usize, col: [u8; 4]) -> usize {
|
||||
bytes.chunks(stride).filter(|v| v.get(24..28) == Some(&col[..])).count()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::doc::{AxisRef, FeatureKind};
|
||||
|
||||
fn make(h: Harness, axis: Value) -> Harness {
|
||||
make_params(h, axis, json!({}))
|
||||
}
|
||||
fn make_params(mut h: Harness, axis: Value, extra: Value) -> Harness {
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Profile"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[10,0],"p1":[15,5]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let mut params = json!({"sketch":"Profile","axis":axis,"angle":"90 deg","operation":"new","name":"Turn","body_name":"Main"});
|
||||
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
h.app.run("solid.revolve", params).unwrap();
|
||||
h.frames(4);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_all_profiles_retains_future_geometry_but_reselection_is_explicit() {
|
||||
for reselect in [false, true] {
|
||||
let mut h = make(Harness::new(), json!("Y"));
|
||||
open(&mut h);
|
||||
if reselect {
|
||||
let selection = h.app.dialog.as_ref().unwrap().inputs[0].items[0].clone();
|
||||
let mut dialog = h.app.dialog.take().unwrap();
|
||||
dialog.active = 0;
|
||||
dialog.pick(&h.app.session, selection.clone());
|
||||
dialog.pick(&h.app.session, selection);
|
||||
h.app.dialog = Some(dialog);
|
||||
h.frames(4);
|
||||
}
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
let FeatureKind::Revolve { profiles, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(matches!(profiles, solvecraft_engine::doc::ProfileSel::All), !reselect);
|
||||
h.app.run("sketch.edit", json!({"sketch":"Profile"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[20,0],"p1":[25,5]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let volume: f64 = state.bodies.iter().map(|b| solvecraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum();
|
||||
let expected = if reselect { 490.873852 } else { 1374.446786 };
|
||||
assert!((volume - expected).abs() < 0.3, "{volume} != {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_object_noop_preserves_cut_targets_and_untargeted_body() {
|
||||
let mut h = Harness::new();
|
||||
for name in ["A", "B"] {
|
||||
h.app.run("solid.box", json!({"length":20,"width":5,"height":20,"corner":[0,0,0],"body_name":name})).unwrap();
|
||||
}
|
||||
let mut h = make_params(h, json!("Y"), json!({"to":[0,2,12],"angle":"45 deg","operation":"cut","targets":["A"]}));
|
||||
let state = h.app.session.world_state();
|
||||
// A quarter annulus separates the stock into inner and outer solids. Both are
|
||||
// results of cutting A; checking only the first named solid loses material.
|
||||
let cut_volume = |state: &solvecraft_engine::doc::ModelState| -> f64 {
|
||||
state.bodies.iter().filter(|b| b.name != "B").map(|b| solvecraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum()
|
||||
};
|
||||
let before_a = cut_volume(&state);
|
||||
let removed = 0.5 * (15.0f64.powi(2) - 10.0f64.powi(2)) * 5.0 * std::f64::consts::FRAC_PI_2;
|
||||
assert!((before_a - (2000.0 - removed)).abs() < 0.2, "cut results {:?}: {before_a}", state.bodies.iter().map(|b| &b.name).collect::<Vec<_>>());
|
||||
let in_cut_stock = |p| state.bodies.iter().filter(|b| b.name != "B").any(|b| b.body.contains(p));
|
||||
assert!(in_cut_stock(solvecraft_engine::geom::Vec3::new(2.0, 2.0, 2.0)));
|
||||
assert!(in_cut_stock(solvecraft_engine::geom::Vec3::new(18.0, 2.0, 18.0)));
|
||||
assert!(!in_cut_stock(solvecraft_engine::geom::Vec3::new(9.0, 2.0, 9.0)));
|
||||
assert!(state.body("B").unwrap().body.contains(solvecraft_engine::geom::Vec3::new(9.0, 2.0, 9.0)));
|
||||
let before_bounds: Vec<_> = state.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none());
|
||||
let state = h.app.session.world_state();
|
||||
assert!((solvecraft_engine::doc::kernel::measure(&state.body("B").unwrap().body).unwrap().volume - 2000.0).abs() < 1e-7);
|
||||
assert!((cut_volume(&state) - before_a).abs() < 1e-7);
|
||||
assert_eq!(state.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect::<Vec<_>>(), before_bounds);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let FeatureKind::Revolve { targets, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(targets, &["A"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_object_noop_retains_dormant_secondary_angle_and_downstream_dimension() {
|
||||
let mut h = make_params(Harness::new(), json!("Y"), json!({"to":[0,2,12],"angle":"45 deg","angle2":"30 deg","direction":"symmetric"}));
|
||||
h.app.run("solid.box", json!({"length":2,"width":2,"height":"(d2 / (1 rad)) * 1 mm","corner":[30,0,0],"body_name":"Dependent"})).unwrap();
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
|
||||
assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::PI / 6.0).abs() < 1e-8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_owner_to_point_remains_exact_on_noop_edit() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make_params(h, json!({"origin":[0,0,0],"dir":[0,2,0]}), json!({"to":[0,2,12],"angle":"45 deg"}));
|
||||
h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap();
|
||||
h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap();
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_active_occurrence_noop_preserves_definition_axis_target_and_instances() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make_params(h, json!({"origin":[0,0,0],"dir":[0,2,0]}), json!({"to":[0,2,12],"angle":"45 deg"}));
|
||||
let second = h.app.run("occurrence.copy", json!({"component":owner["component"],"translate":[100,0,20]})).unwrap()["occurrence"].clone();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"axis":[0,0,1],"angle":"17 deg"})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":owner["component"],"occurrence":second,"path":[second]})).unwrap();
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let frame = h.app.session.active_component_frame();
|
||||
let expected_origin = solvecraft_engine::doc::apply_point(&frame, solvecraft_engine::geom::Vec3::ZERO);
|
||||
let before: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
|
||||
open(&mut h);
|
||||
let (origin, _) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert!((origin - expected_origin).len() < 1e-8);
|
||||
assert!(origin.len() > 50.0, "fixture must distinguish first and second placements");
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview_world(&commands).unwrap();
|
||||
let placed = preview.after.bodies.iter().find(|body| body.mesh().bounds().center().x > 50.0).unwrap();
|
||||
assert!(placed.mesh().triangles.len() > 10);
|
||||
assert_eq!(placed.mesh().bounds(), before.iter().find(|(_, bounds)| bounds.center().x > 50.0).unwrap().1);
|
||||
assert!(h.widget("↖ Custom Axis").is_some());
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
let after: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
|
||||
assert_eq!(after, before);
|
||||
assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&vec![second.as_u64().unwrap()]));
|
||||
}
|
||||
fn axis(h: &Harness) -> Value {
|
||||
let FeatureKind::Revolve { axis, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
serde_json::to_value(axis).unwrap()
|
||||
}
|
||||
fn open(h: &mut Harness) {
|
||||
h.step(&json!({"call":"ui.editFeature","params":{"feature":"Turn"}})).unwrap();
|
||||
h.frames(8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_noop_editor_retains_all_revolve_axis_kinds_and_analytic_volume() {
|
||||
for axis_value in [json!("Y"), json!("y"), json!("l4"), json!({"origin":[0,0,0],"dir":[0,2,0]})] {
|
||||
let mut h = make(Harness::new(), axis_value.clone());
|
||||
let original = axis(&h);
|
||||
let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone();
|
||||
let state = h.app.session.world_state();
|
||||
let body = state.body("Main").unwrap();
|
||||
let bounds = body.mesh().bounds();
|
||||
let radii = if axis_value == json!("l4") { 25.0 } else { 225.0 - 100.0 };
|
||||
let expected = 0.5 * radii * 5.0 * std::f64::consts::FRAC_PI_2;
|
||||
let volume = solvecraft_engine::doc::kernel::measure(&body.body).unwrap().volume;
|
||||
assert!((volume - expected).abs() < 0.03, "{axis_value}: {volume}");
|
||||
open(&mut h);
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview(&commands).unwrap();
|
||||
assert_eq!(preview.after.body("Main").unwrap().mesh().bounds(), bounds);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{axis_value}: {:?}", h.app.session.log);
|
||||
assert_eq!(axis(&h), original);
|
||||
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_angle_edit_preserves_axis_downstream_binding_and_one_undo() {
|
||||
let mut h = make(Harness::new(), json!({"origin":[0,0,0],"dir":[0,2,0]}));
|
||||
h.app.run("solid.box", json!({"length":2,"width":2,"height":"(d1 / (1 rad)) * 1 mm","corner":[30,0,0],"body_name":"Dependent"})).unwrap();
|
||||
let original = axis(&h);
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"180 deg"})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(axis(&h), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::PI).abs() < 1e-8);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(axis(&h), original);
|
||||
assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::FRAC_PI_2).abs() < 1e-8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_owner_explicit_axis_is_lossless_with_another_component_active() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make(h, json!({"origin":[0,0,0],"dir":[0,2,0]}));
|
||||
h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap();
|
||||
h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap();
|
||||
let active = h.app.session.active_component;
|
||||
let original = axis(&h);
|
||||
let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
open(&mut h);
|
||||
let (origin, direction) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert!((origin.x - 100.0).abs() < 1e-8 && (origin.z - 20.0).abs() < 1e-8);
|
||||
let angle = 17.0f64.to_radians();
|
||||
assert!((direction.x + angle.sin()).abs() < 1e-8 && (direction.y - angle.cos()).abs() < 1e-8);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(axis(&h), original);
|
||||
assert_eq!(h.app.session.active_component, active);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_and_sketch_axis_handles_follow_authoring_occurrence() {
|
||||
for selected in [json!("Y"), json!("y")] {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make(h, selected);
|
||||
h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap();
|
||||
let original = axis(&h);
|
||||
open(&mut h);
|
||||
let (origin, direction) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let angle = 17.0f64.to_radians();
|
||||
assert!((origin.x - 100.0).abs() < 1e-8 && (origin.z - 20.0).abs() < 1e-8);
|
||||
assert!((direction.x + angle.sin()).abs() < 1e-8 && (direction.y - angle.cos()).abs() < 1e-8);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(axis(&h), original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_axis_cancel_restores_original_document_and_history() {
|
||||
let mut h = make(Harness::new(), json!("y"));
|
||||
let original = serde_json::to_value(&*h.app.session.doc).unwrap();
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"180 deg"})).unwrap();
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&*h.app.session.doc).unwrap(), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert!(matches!(&h.app.session.doc.find_feature("Turn").unwrap().kind,FeatureKind::Revolve{axis:AxisRef::SketchAxis{axis},..} if axis=="y"));
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::doc::{FeatureKind, kernel};
|
||||
|
||||
fn fixture(mut h: Harness, extra: Value) -> Harness {
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Profile"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[10,0],"p1":[15,5]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let mut params = json!({"sketch":"Profile","axis":"Y","angle":"90 deg","operation":"new","name":"Turn","body_name":"Main"});
|
||||
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
h.app.run("solid.revolve", params).unwrap();
|
||||
h.frames(4);
|
||||
h
|
||||
}
|
||||
fn open(h: &mut Harness) {
|
||||
h.step(&json!({"call":"ui.view","params":{"view":"front"}})).unwrap();
|
||||
h.step(&json!({"call":"ui.view","params":{"view":"fit"}})).unwrap();
|
||||
h.step(&json!({"call":"ui.scroll","params":{"x":700,"y":450,"delta":-400}})).unwrap();
|
||||
h.step(&json!({"call":"ui.editFeature","params":{"feature":"Turn"}})).unwrap();
|
||||
h.frames(8);
|
||||
}
|
||||
fn at_angle(degrees: f64) -> Value {
|
||||
let a = degrees.to_radians();
|
||||
json!([12.5 * a.cos(), 2.5, 12.5 * a.sin()])
|
||||
}
|
||||
fn handle_at(h: &mut Harness, name: &str, world: Value) {
|
||||
let actual = h.at(&json!({"handle":name})).unwrap();
|
||||
let expected = h.at(&json!({"world":world})).unwrap();
|
||||
assert!((actual[0] - expected[0]).hypot(actual[1] - expected[1]) < 1.0, "{actual:?} != {expected:?}");
|
||||
}
|
||||
fn volume(h: &Harness) -> f64 {
|
||||
kernel::measure(&h.app.session.world_state().body("Main").unwrap().body).unwrap().volume
|
||||
}
|
||||
fn angle(h: &Harness) -> String {
|
||||
let FeatureKind::Revolve { angle, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
angle.clone()
|
||||
}
|
||||
fn expected_volume(degrees: f64) -> f64 {
|
||||
0.5 * (225.0 - 100.0) * 5.0 * degrees.to_radians()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_positive_negative_axis_and_negative_sweep_drags_follow_geometry() {
|
||||
for (params, sign) in [(json!({}), 1.0), (json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]}}), 1.0), (json!({"angle":"-90 deg"}), -1.0)] {
|
||||
let mut h = fixture(Harness::new(), params.clone());
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone();
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
handle_at(&mut h, "revolve", at_angle(sign * 90.0));
|
||||
h.step(&json!({"drag":[{"handle":"revolve"},{"world":at_angle(sign * 60.0)}]})).unwrap();
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview_world(&commands).unwrap();
|
||||
assert!((kernel::measure(&preview.after.body("Main").unwrap().body).unwrap().volume - expected_volume(60.0)).abs() < 0.03);
|
||||
let bounds = preview.after.body("Main").unwrap().mesh().bounds();
|
||||
if sign > 0.0 {
|
||||
assert!(bounds.min.z > -1e-8 && bounds.max.z > 12.9);
|
||||
} else {
|
||||
assert!(bounds.max.z < 1e-6 && bounds.min.z < -12.9, "{params}: {bounds:?}");
|
||||
}
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(angle(&h), if params.get("angle").is_some() { "-60 deg" } else { "60 deg" });
|
||||
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
|
||||
assert!((volume(&h) - expected_volume(60.0)).abs() < 0.03);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_boundary_drags_edit_included_angle_and_cancel_is_lossless() {
|
||||
for (handle, sign) in [("revolve", 1.0), ("revolve2", -1.0)] {
|
||||
let mut h = fixture(Harness::new(), json!({"angle":"120 deg","direction":"symmetric"}));
|
||||
let original = serde_json::to_value(&*h.app.session.doc).unwrap();
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
handle_at(&mut h, "revolve", at_angle(60.0));
|
||||
handle_at(&mut h, "revolve2", at_angle(-60.0));
|
||||
h.step(&json!({"drag":[{"handle":handle},{"world":at_angle(sign * 45.0)}]})).unwrap();
|
||||
let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(entered, "90 deg");
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&*h.app.session.doc).unwrap(), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
open(&mut h);
|
||||
h.step(&json!({"drag":[{"handle":handle},{"world":at_angle(sign * 45.0)}]})).unwrap();
|
||||
let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(entered, "90 deg", "second {handle} drag");
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!((volume(&h) - expected_volume(90.0)).abs() < 0.1, "{handle}: {} angle {}", volume(&h), angle(&h));
|
||||
let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
assert!((bounds.max.z - 15.0 / 2.0f64.sqrt()).abs() < 0.03 && (bounds.min.z + 15.0 / 2.0f64.sqrt()).abs() < 0.03);
|
||||
let path = h.dir.join("symmetric.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
assert_eq!(angle(&h), "90 deg");
|
||||
assert!((volume(&h) - expected_volume(90.0)).abs() < 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_object_displays_derived_sweep_without_dormant_edit_until_angle_selected() {
|
||||
let mut h = fixture(Harness::new(), json!({"angle":"45 deg","angle2":"30 deg","to":[0,2,12]}));
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
open(&mut h);
|
||||
assert!(h.widget("Sweep 90.0°").is_some());
|
||||
assert!(crate::scenario::handle_at("revolve").is_none());
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"180 deg"})).unwrap();
|
||||
let crate::dialogs::Kind::Revolve { angle, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(angle, "45 deg");
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
open(&mut h);
|
||||
h.step(&json!({"click":{"handle":"dialog:Type"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Type:Angle"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Angle"}})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"90 deg"})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
let FeatureKind::Revolve { to, angle2, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert!(to.is_none());
|
||||
assert_eq!(angle2.as_deref(), Some("30 deg"));
|
||||
assert!((volume(&h) - expected_volume(120.0)).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_side_boundary_preserves_other_angle_and_parameter_binding() {
|
||||
let mut h = fixture(Harness::new(), json!({"angle":"60 deg","angle2":"30 deg"}));
|
||||
h.app.run("solid.box", json!({"length":1,"width":1,"height":"(d2 / (1 rad)) * 1 mm","corner":[30,0,0],"body_name":"Dependent"})).unwrap();
|
||||
let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone();
|
||||
open(&mut h);
|
||||
handle_at(&mut h, "revolve", at_angle(60.0));
|
||||
handle_at(&mut h, "revolve2", at_angle(-30.0));
|
||||
h.step(&json!({"drag":[{"handle":"revolve2"},{"world":at_angle(-45.0)}]})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
let FeatureKind::Revolve { angle, angle2, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(angle, "60 deg");
|
||||
assert_eq!(angle2.as_deref(), Some("45 deg"));
|
||||
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
|
||||
assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::FRAC_PI_4).abs() < 1e-8);
|
||||
assert!((volume(&h) - expected_volume(105.0)).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_second_placement_custom_axis_handle_and_preview_stay_in_its_frame() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = fixture(h, json!({"axis":{"origin":[0,0,0],"dir":[0,2,0]}}));
|
||||
let second = h.app.run("occurrence.copy", json!({"component":owner["component"],"translate":[100,0,20]})).unwrap()["occurrence"].clone();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"axis":[0,0,1],"angle":"17 deg"})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":owner["component"],"occurrence":second,"path":[second]})).unwrap();
|
||||
let frame = h.app.session.active_component_frame();
|
||||
let world = |degrees: f64| {
|
||||
let a = degrees.to_radians();
|
||||
let p = solvecraft_engine::doc::apply_point(&frame, solvecraft_engine::geom::Vec3::new(12.5 * a.cos(), 2.5, 12.5 * a.sin()));
|
||||
json!([p.x, p.y, p.z])
|
||||
};
|
||||
let axis = match &h.app.session.doc.find_feature("Turn").unwrap().kind {
|
||||
FeatureKind::Revolve { axis, .. } => axis.clone(),
|
||||
_ => panic!("revolve"),
|
||||
};
|
||||
open(&mut h);
|
||||
assert!(h.widget("↖ Custom Axis").is_some());
|
||||
handle_at(&mut h, "revolve", world(90.0));
|
||||
h.step(&json!({"drag":[{"handle":"revolve"},{"world":world(60.0)}]})).unwrap();
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview_world(&commands).unwrap();
|
||||
let placed = preview.after.bodies.iter().find(|body| body.mesh().bounds().center().x > 50.0).unwrap();
|
||||
assert!((kernel::measure(&placed.body).unwrap().volume - expected_volume(60.0)).abs() < 0.03);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
let FeatureKind::Revolve { axis: after, angle, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(after, &axis);
|
||||
assert_eq!(angle, "60 deg");
|
||||
assert_eq!(h.app.session.active_occurrence_path.as_deref(), Some(&[second.as_u64().unwrap()][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_to_object_has_surface_feedback_without_an_angular_handle_or_input() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.cylinder", json!({"radius":3,"height":5,"base":[0,0,12],"axis":"Y","operation":"new","body_name":"Target"})).unwrap();
|
||||
let mut h = fixture(h, json!({}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"click":{"handle":"dialog:Type"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Type:To Object"}})).unwrap();
|
||||
let mut dialog = h.app.dialog.take().unwrap();
|
||||
dialog.active = dialog.inputs.iter().position(|input| input.label == "To Object").unwrap();
|
||||
dialog.pick(&h.app.session, solvecraft_engine::Sel::Vertex { body: "Target".into(), point: solvecraft_engine::geom::Vec3::new(0.0, 2.0, 15.0) });
|
||||
h.app.dialog = Some(dialog);
|
||||
h.frames(8);
|
||||
assert!(h.widget("To surface").is_some());
|
||||
assert!(crate::scenario::handle_at("revolve").is_none());
|
||||
assert!(crate::scenario::handle_at("revolve2").is_none());
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"180 deg"})).unwrap();
|
||||
let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(entered, "90 deg");
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(angle(&h), "90 deg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_drag_and_invalid_expression_stay_editable_before_commit() {
|
||||
let mut h = fixture(Harness::new(), json!({}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"drag":[{"handle":"revolve"},{"world":at_angle(37.3)}],"ctrl":true})).unwrap();
|
||||
let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
|
||||
assert!((h.app.session.doc.eval(entered, solvecraft_engine::doc::expr::Kind::Angle).unwrap().to_degrees() - 37.3).abs() < 0.2);
|
||||
h.step(&json!({"click":{"handle":"dialog:Angle"}})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"unknown_angle"})).unwrap();
|
||||
h.frames(4);
|
||||
assert!(h.widget("unknown_angle").is_some());
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_some());
|
||||
h.step(&json!({"click":{"handle":"dialog:Angle"}})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"60 deg"})).unwrap();
|
||||
let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(entered, "60 deg");
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().map(|dialog| &dialog.error));
|
||||
assert_eq!(angle(&h), "60 deg");
|
||||
assert!((volume(&h) - expected_volume(60.0)).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_side_can_be_dragged_to_zero_without_becoming_full_turn() {
|
||||
let mut h = fixture(Harness::new(), json!({"angle":"60 deg","angle2":"30 deg"}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"drag":[{"handle":"revolve2"},{"world":at_angle(0.0)}]})).unwrap();
|
||||
assert_eq!(h.app.dialog.as_ref().unwrap().extra.get("angle2"), Some(&json!("0 deg")));
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
let FeatureKind::Revolve { angle2, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(angle2.as_deref(), Some("0 deg"));
|
||||
assert!((volume(&h) - expected_volume(60.0)).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_on_ring_drag_uses_nearest_projected_branch() {
|
||||
let mut h = fixture(Harness::new(), json!({}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"call":"ui.view","params":{"view":"top"}})).unwrap();
|
||||
handle_at(&mut h, "revolve", at_angle(90.0));
|
||||
h.step(&json!({"drag":[{"handle":"revolve"},{"world":at_angle(60.0)}]})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(angle(&h), "60 deg");
|
||||
assert!((volume(&h) - expected_volume(60.0)).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_reversed_axis_symmetric_interval_is_shown_and_preserved_exactly() {
|
||||
let mut h = fixture(
|
||||
Harness::new(),
|
||||
json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]},"direction":"symmetric","angle":"120 deg","convention":"legacy_axis_start"}),
|
||||
);
|
||||
// Exercise an actual older design: no convention marker was stored by version 10.
|
||||
let mut saved: Value = serde_json::from_str(&h.app.session.doc.to_json()).unwrap();
|
||||
saved["format"] = json!("solvecraft/10");
|
||||
for feature in saved["features"].as_array_mut().unwrap() {
|
||||
feature.as_object_mut().unwrap().remove("convention");
|
||||
}
|
||||
let path = h.dir.join("legacy-revolve.solvecraft");
|
||||
std::fs::write(&path, serde_json::to_vec(&saved).unwrap()).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
let FeatureKind::Revolve { convention, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(*convention, solvecraft_engine::doc::RevolveConvention::LegacyAxisStart);
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let before = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
assert!(before.min.x < -14.9 && before.max.x < 7.6 && before.min.z > -1e-6 && before.max.z > 14.9);
|
||||
open(&mut h);
|
||||
handle_at(&mut h, "revolve", at_angle(180.0));
|
||||
handle_at(&mut h, "revolve2", at_angle(60.0));
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), before);
|
||||
open(&mut h);
|
||||
h.step(&json!({"drag":[{"handle":"revolve"},{"world":at_angle(135.0)}]})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(angle(&h), "90 deg");
|
||||
let after = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
assert!((after.min.x + 15.0 / 2.0f64.sqrt()).abs() < 0.03 && (after.max.x - 15.0 / 2.0f64.sqrt()).abs() < 0.03);
|
||||
assert!(after.min.z > 7.0 && after.max.z > 14.9);
|
||||
assert!((volume(&h) - expected_volume(90.0)).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_symmetric_ring_preview_drag_and_reopen_match_material() {
|
||||
for axis in [json!("Y"), json!({"origin":[0,0,0],"dir":[0,-2,0]})] {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Profile"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[4,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app
|
||||
.run("solid.revolve", json!({"sketch":"Profile","axis":axis,"angle":"90 deg","direction":"symmetric","name":"Turn","body_name":"Main"}))
|
||||
.unwrap();
|
||||
let before = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone();
|
||||
open(&mut h);
|
||||
let at = |degrees: f64| {
|
||||
let a = degrees.to_radians();
|
||||
json!([3.0 * a.cos(), 5.0, 3.0 * a.sin()])
|
||||
};
|
||||
handle_at(&mut h, "revolve", at(45.0));
|
||||
handle_at(&mut h, "revolve2", at(-45.0));
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview_world(&commands).unwrap();
|
||||
let body = &preview.after.body("Main").unwrap().body;
|
||||
assert!(body.contains(solvecraft_engine::geom::Vec3::new(3.0, 5.0, 0.0)));
|
||||
assert!(!body.contains(solvecraft_engine::geom::Vec3::new(0.0, 5.0, 3.0)));
|
||||
let bounds = preview.after.body("Main").unwrap().mesh().bounds();
|
||||
assert!((bounds.min.x - 2.0f64.sqrt()).abs() < 0.02 && (bounds.max.x - 4.0).abs() < 0.02, "{bounds:?}");
|
||||
assert!((bounds.min.z + 2.0 * 2.0f64.sqrt()).abs() < 0.02 && (bounds.max.z - 2.0 * 2.0f64.sqrt()).abs() < 0.02);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), before);
|
||||
open(&mut h);
|
||||
h.step(&json!({"drag":[{"handle":"revolve2"},{"world":at(-30.0)}]})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(angle(&h), "60 deg");
|
||||
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
|
||||
assert!((volume(&h) - 60.0 * std::f64::consts::FRAC_PI_3).abs() < 0.03);
|
||||
let path = h.dir.join("canonical-revolve.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
open(&mut h);
|
||||
handle_at(&mut h, "revolve", at(30.0));
|
||||
handle_at(&mut h, "revolve2", at(-30.0));
|
||||
let FeatureKind::Revolve { convention, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(*convention, solvecraft_engine::doc::RevolveConvention::SketchNormal);
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversed_axis_to_object_canvas_sweep_and_preview_follow_the_quarter_target() {
|
||||
let mut h = fixture(Harness::new(), json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]},"to":[0,2,12],"angle":"45 deg"}));
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone();
|
||||
let before = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
let p = 12.5 / 2.0f64.sqrt();
|
||||
open(&mut h);
|
||||
assert!(h.widget("Sweep 90.0°").is_some());
|
||||
assert!(crate::scenario::handle_at("revolve").is_none());
|
||||
assert!(crate::scenario::handle_at("revolve2").is_none());
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview_world(&commands).unwrap();
|
||||
let result = preview.after.body("Main").unwrap();
|
||||
assert_eq!(result.mesh().bounds(), before);
|
||||
assert!(result.body.contains(solvecraft_engine::geom::Vec3::new(p, 2.5, p)));
|
||||
for (x, z) in [(-p, p), (-p, -p), (p, -p)] {
|
||||
assert!(!result.body.contains(solvecraft_engine::geom::Vec3::new(x, 2.5, z)));
|
||||
}
|
||||
assert!((kernel::measure(&result.body).unwrap().volume - expected_volume(90.0)).abs() < 0.03);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
|
||||
let path = h.dir.join("reversed-to-object.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
open(&mut h);
|
||||
assert!(h.widget("Sweep 90.0°").is_some());
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), before);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Real 3D drawing and triad input in a rotated second component occurrence.
|
||||
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
|
||||
#[test]
|
||||
fn second_occurrence_drawn_wire_fit_picks_and_world_triad_drag_stay_aligned() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let component = h.app.run("component.create", json!({"name":"Part"})).unwrap()["component"].as_u64().unwrap();
|
||||
let first = h.app.session.doc.occurrence_of(component).unwrap().id;
|
||||
let second = h.app.run("occurrence.copy", json!({"component":component})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"translate":[20,30,40],"axis":[0,0,1],"angle":"90 deg","capture":true})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":component,"occurrence":second})).unwrap();
|
||||
h.app.run("solid.box", json!({"corner":[17,32,45],"length":2,"width":2,"height":10,"body_name":"SnapVertices"})).unwrap();
|
||||
h.app.run("browser.visibility", json!({"paths":[[first]],"visible":false})).unwrap();
|
||||
let sketch = h.app.run("sketch.create", json!({"plane":"XY","name":"Wire"})).unwrap()["sketch"].as_u64().unwrap();
|
||||
h.app.ui.show_origin = false;
|
||||
h.settle();
|
||||
h.step(&json!({"widget":"3D Sketch"})).unwrap();
|
||||
assert!(
|
||||
crate::sketch3d::active(&h.app),
|
||||
"palette click must enable 3D: raw={:?}, world={:?}, status={:?}, active={:?}",
|
||||
h.app.session.model.state().sketch(sketch).unwrap().sketch.view,
|
||||
h.app.session.world_state().sketch(sketch).unwrap().sketch.view,
|
||||
h.app.status,
|
||||
h.app.session.active_sketch
|
||||
);
|
||||
h.call("ui.view", json!({"view":"home"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.app.start("sketch.line");
|
||||
h.frames(2);
|
||||
for world in [Vec3::new(17.0, 32.0, 45.0), Vec3::new(17.0, 32.0, 55.0)] {
|
||||
let [x, y] = h.at(&json!({"world":[world.x,world.y,world.z]})).unwrap();
|
||||
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap());
|
||||
let picked = crate::sketch3d::point_at(&h.app, &proj, egui::pos2(x as f32, y as f32));
|
||||
assert!(picked.is_some_and(|(p, _)| p.dist(world) < 1e-5), "world={world:?}, picked={picked:?}, status={:?}", h.app.status);
|
||||
}
|
||||
h.step(&json!({"click":{"world":[17,32,45]}})).unwrap();
|
||||
h.step(&json!({"click":{"world":[17,32,55]}})).unwrap();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.tool.is_none());
|
||||
let raw = h.app.session.model.state();
|
||||
let wire = raw.sketch(sketch).unwrap().sketch.wires.first().expect("actual off-plane clicks create a wire");
|
||||
assert!(wire.fit[0].dist(Vec3::new(2.0, 3.0, 5.0)) < 1e-6, "{:?}", wire.fit);
|
||||
assert!(wire.fit[1].dist(Vec3::new(2.0, 3.0, 15.0)) < 1e-6, "{:?}", wire.fit);
|
||||
let world = h.app.session.world_state();
|
||||
let shown = &world.sketch(sketch).unwrap().sketch.wires[0];
|
||||
assert!(shown.fit[0].dist(Vec3::new(17.0, 32.0, 45.0)) < 1e-6, "{:?}", shown.fit);
|
||||
assert!(shown.fit[1].dist(Vec3::new(17.0, 32.0, 55.0)) < 1e-6, "{:?}", shown.fit);
|
||||
h.step(&json!({"click":{"world":[17,32,45]}})).unwrap();
|
||||
let [x0, y0] = h.at(&json!({"handle":"triad_x"})).unwrap();
|
||||
let before = h.at(&json!({"world":[17,32,45]})).unwrap();
|
||||
let after = h.at(&json!({"world":[21,32,45]})).unwrap();
|
||||
let history = h.app.session.undo.len();
|
||||
h.call("ui.drag", json!({"x0":x0,"y0":y0,"x1":x0+after[0]-before[0],"y1":y0+after[1]-before[1],"steps":8}));
|
||||
let moved = h.app.session.model.state();
|
||||
let fit = &moved.sketch(sketch).unwrap().sketch.wires[0].fit;
|
||||
assert!(fit[0].dist(Vec3::new(2.0, -1.0, 5.0)) < 1e-5, "{fit:?}");
|
||||
assert!(fit[1].dist(Vec3::new(2.0, 3.0, 15.0)) < 1e-6);
|
||||
let placed = h.app.session.world_state();
|
||||
assert!(placed.sketch(sketch).unwrap().sketch.wires[0].fit[0].dist(Vec3::new(21.0, 32.0, 45.0)) < 1e-5);
|
||||
assert_eq!(h.app.session.undo.len(), history + 1);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
let restored = h.app.session.model.state();
|
||||
assert!(restored.sketch(sketch).unwrap().sketch.wires[0].fit[0].dist(Vec3::new(2.0, 3.0, 5.0)) < 1e-6);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Reviewable sketch-plane rebinding. Saved lost planes are repair guides, never model geometry.
|
||||
|
||||
use egui::{Pos2, RichText};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::Sel;
|
||||
use solvecraft_engine::doc::face_anchor::plane_face_anchor;
|
||||
use solvecraft_engine::doc::{EmbossFace, FaceAttachmentStatus, FeatureKind, mat_inverse};
|
||||
use solvecraft_engine::geom::{Plane, Vec3};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Draft {
|
||||
pub sketch: u64,
|
||||
pub candidate: Option<Value>,
|
||||
pub label: String,
|
||||
pub error: Option<String>,
|
||||
pub author_path: Option<Vec<u64>>,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
pub fn new(app: &SolveApp, sketch: u64) -> Self {
|
||||
let author_path = app.session.doc.feature(sketch).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } if plane_face_anchor(plane).is_some() => {
|
||||
plane_face_anchor(plane).map(|anchor| anchor.author_path.clone())
|
||||
}
|
||||
_ if feature.component == app.session.active_component => app.session.active_occurrence_path.clone(),
|
||||
_ => None,
|
||||
});
|
||||
Self { sketch, candidate: None, label: String::new(), error: None, author_path }
|
||||
}
|
||||
|
||||
fn choose(&mut self, plane: Value, label: String) {
|
||||
self.candidate = Some(plane);
|
||||
self.label = label;
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
pub fn commands(&self) -> Option<Vec<(String, Value)>> {
|
||||
let mut params = json!({"sketch":self.sketch,"plane":self.candidate.as_ref()?});
|
||||
if let Some(path) = &self.author_path {
|
||||
params["author_path"] = json!(path);
|
||||
}
|
||||
Some(vec![("sketch.redefine".into(), params)])
|
||||
}
|
||||
|
||||
pub fn choose_saved_fixed(&mut self, app: &SolveApp, world: Plane) -> Result<(), String> {
|
||||
let doc = app.session.placed_document();
|
||||
let owner = doc.feature(self.sketch).ok_or("Sketch no longer exists")?.component;
|
||||
let path = self.author_path.clone().unwrap_or_else(|| doc.first_path(owner));
|
||||
if self.author_path.is_none()
|
||||
&& path.iter().any(|id| {
|
||||
doc.occurrences
|
||||
.iter()
|
||||
.find(|o| o.id == *id)
|
||||
.is_none_or(|selected| doc.occurrences.iter().filter(|o| o.component == selected.component).count() != 1)
|
||||
})
|
||||
{
|
||||
return Err("Select an exact author placement before converting this plane".into());
|
||||
}
|
||||
let frame = EmbossFace::component_frame(&doc, owner, &path).map_err(|error| error.to_string())?;
|
||||
mat_inverse(&frame).ok_or("Sketch author frame is invalid")?;
|
||||
self.author_path = Some(path);
|
||||
// Public commands take world geometry. Session maps Custom planes into
|
||||
// this exact author placement once, including redefine's saved path.
|
||||
self.choose(fixed_plane(world), "Fixed plane: independent of the source body".into());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(app: &mut SolveApp) {
|
||||
app.tree.redefine = None;
|
||||
app.tree.redefine_draft = None;
|
||||
}
|
||||
|
||||
pub fn commands(app: &SolveApp) -> Option<Vec<(String, Value)>> {
|
||||
let draft = app.tree.redefine_draft.as_ref()?;
|
||||
(app.tree.redefine == Some(draft.sketch)).then(|| draft.commands()).flatten()
|
||||
}
|
||||
|
||||
fn attachment(app: &SolveApp, sketch: u64) -> FaceAttachmentStatus {
|
||||
app.session.placed_document().sketch_face_attachment(sketch, &app.session.model)
|
||||
}
|
||||
|
||||
fn fixed_plane(plane: Plane) -> Value {
|
||||
json!({"origin":plane.origin,"x_dir":plane.x,"y_dir":plane.y})
|
||||
}
|
||||
|
||||
pub fn face_plane(app: &SolveApp, selection: &Sel) -> Option<Value> {
|
||||
match selection {
|
||||
Sel::Face { body, index, point } => {
|
||||
let doc = app.session.placed_document();
|
||||
let (_, _, _, path, _) = doc.body_frames_with_source(&app.session.model.state()).into_iter().find(|(world, _, _, _, _)| world == body)?;
|
||||
Some(json!({"face":point,"body":body,"index":index,"path":path}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn face_label(app: &SolveApp, selection: &Sel) -> String {
|
||||
let Sel::Face { body, .. } = selection else { return String::new() };
|
||||
let placed = app.session.placed_document();
|
||||
if let Some((_, source, _, path, _)) =
|
||||
placed.body_frames_with_source(&app.session.model.state()).into_iter().find(|(world, _, _, _, _)| world == body)
|
||||
{
|
||||
format!("Face on {source} / {}", path_label(app, &path))
|
||||
} else {
|
||||
format!("Face on {body}")
|
||||
}
|
||||
}
|
||||
|
||||
fn path_label(app: &SolveApp, path: &[u64]) -> String {
|
||||
if path.is_empty() {
|
||||
return "Root".into();
|
||||
}
|
||||
(1..=path.len())
|
||||
.map(|end| app.session.occurrence_placement(&path[..end]).map(|placement| placement.name).unwrap_or_else(|_| "Missing occurrence".into()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ")
|
||||
}
|
||||
|
||||
pub fn panel(app: &mut SolveApp, ctx: &egui::Context, at: Pos2) {
|
||||
let Some(sketch) = app.tree.redefine else { return };
|
||||
if app.session.doc.feature(sketch).is_none() {
|
||||
clear(app);
|
||||
return;
|
||||
}
|
||||
let mut draft = app.tree.redefine_draft.take().filter(|draft| draft.sketch == sketch).unwrap_or_else(|| Draft::new(app, sketch));
|
||||
let status = attachment(app, sketch);
|
||||
let name = app.session.doc.feature(sketch).map(|feature| feature.name.as_str()).unwrap_or("Sketch");
|
||||
let selected = app.session.selection.iter().find(|selection| matches!(selection, Sel::Face { .. })).cloned();
|
||||
let mut open = true;
|
||||
let mut apply = false;
|
||||
let mut cancel = false;
|
||||
let t = Tokens::get();
|
||||
crate::frame::docked(
|
||||
format!("Redefine Sketch Plane: {name}"),
|
||||
egui::Id::new("sc_redefine"),
|
||||
at,
|
||||
app.viewport.rect.map(|rect| rect.bottom()).unwrap_or_else(|| ctx.content_rect().bottom()) - at.y - 8.0,
|
||||
crate::frame::Width::Normal,
|
||||
)
|
||||
.open(&mut open)
|
||||
.scroll([false, true])
|
||||
.show(ctx, |ui| {
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
ui.strong(format!("Redefine Sketch Plane: {name}"));
|
||||
ui.separator();
|
||||
let saved_plane = match &status {
|
||||
FaceAttachmentStatus::Healthy { plane, target, path } => {
|
||||
ui.label(format!("Attached to {} / {}", target.body, path_label(app, path)));
|
||||
Some(*plane)
|
||||
}
|
||||
FaceAttachmentStatus::Lost { message, repair_plane } => {
|
||||
ui.label(RichText::new("Face attachment lost").color(t.error));
|
||||
ui.label(message);
|
||||
if repair_plane.is_some() {
|
||||
ui.label("The dashed sketch is a saved plane guide. Select a new face to repair the attachment.");
|
||||
} else {
|
||||
ui.label("The saved author occurrence is missing. Select a valid author placement before rebinding.");
|
||||
}
|
||||
*repair_plane
|
||||
}
|
||||
FaceAttachmentStatus::Legacy { plane } => {
|
||||
ui.label("Legacy face attachment. Rebinding to a certified face creates an exact attachment.");
|
||||
*plane
|
||||
}
|
||||
FaceAttachmentStatus::Fixed { plane } => {
|
||||
ui.label("Sketch plane has no body-face attachment.");
|
||||
*plane
|
||||
}
|
||||
};
|
||||
ui.separator();
|
||||
if matches!(&status, FaceAttachmentStatus::Lost { repair_plane: None, .. }) {
|
||||
let author = app.session.doc.feature(sketch).map(|feature| feature.component);
|
||||
let available = author == Some(app.session.active_component);
|
||||
if ui.add_enabled(available, egui::Button::new("Use active author placement")).clicked() {
|
||||
draft.author_path = Some(app.session.active_occurrence_path.clone().unwrap_or_default());
|
||||
draft.error = None;
|
||||
}
|
||||
if available && let Some(path) = &draft.author_path {
|
||||
ui.label(format!("Author placement: {}", path_label(app, path)));
|
||||
}
|
||||
}
|
||||
ui.label("Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.");
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for plane in ["XY", "XZ", "YZ"] {
|
||||
if ui.button(format!("Use {plane} plane")).clicked() {
|
||||
draft.choose(json!(plane), plane.into());
|
||||
}
|
||||
}
|
||||
});
|
||||
for feature in &app.session.doc.features {
|
||||
if matches!(feature.kind, FeatureKind::ConstructionPlane { .. }) && ui.button(&feature.name).clicked() {
|
||||
draft.choose(json!(feature.name), feature.name.clone());
|
||||
}
|
||||
}
|
||||
if ui.add_enabled(selected.is_some(), egui::Button::new("Reattach selected face")).clicked()
|
||||
&& let Some(selection @ Sel::Face { .. }) = &selected
|
||||
&& let Some(plane) = face_plane(app, selection)
|
||||
{
|
||||
let label = face_label(app, selection);
|
||||
draft.choose(plane, label);
|
||||
}
|
||||
if ui.add_enabled(saved_plane.is_some(), egui::Button::new("Use saved fixed plane")).clicked()
|
||||
&& let Some(plane) = saved_plane
|
||||
&& let Err(error) = draft.choose_saved_fixed(app, plane)
|
||||
{
|
||||
draft.error = Some(error);
|
||||
}
|
||||
if ui.add_enabled(selected.is_some(), egui::Button::new("Use selected face as fixed plane")).clicked()
|
||||
&& let Some(selection) = &selected
|
||||
&& let Some(mut plane) = face_plane(app, selection)
|
||||
{
|
||||
plane["fixed"] = json!(true);
|
||||
draft.choose(plane, "Fixed plane: independent of the selected body".into());
|
||||
}
|
||||
if draft.candidate.is_some() {
|
||||
ui.separator();
|
||||
ui.label(format!("Preview: {}", draft.label));
|
||||
}
|
||||
if let Some(error) = draft.error.as_ref().or(app.preview.error.as_ref()) {
|
||||
ui.label(RichText::new(error).color(t.error));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add_enabled(draft.candidate.is_some() && app.preview.error.is_none(), egui::Button::new("Apply")).clicked() {
|
||||
apply = true;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if ctx.input(|input| input.key_pressed(egui::Key::Escape)) {
|
||||
cancel = true;
|
||||
app.esc_handled = true;
|
||||
}
|
||||
if apply && let Some(commands) = draft.commands() {
|
||||
for (command, params) in commands {
|
||||
match app.run(&command, params) {
|
||||
Ok(_) => cancel = true,
|
||||
Err(error) => draft.error = Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel || !open {
|
||||
clear(app);
|
||||
} else {
|
||||
app.tree.redefine_draft = Some(draft);
|
||||
}
|
||||
}
|
||||
|
||||
/// Only a lost reference's explicit saved author frame supplies a guide. It is
|
||||
/// absent from ModelState, selection, snapping and all downstream evaluation.
|
||||
pub fn lost_guide(app: &SolveApp) -> Option<(Plane, &solvecraft_engine::sketch::Sketch)> {
|
||||
let sketch = app.tree.redefine?;
|
||||
let FaceAttachmentStatus::Lost { repair_plane: Some(plane), .. } = attachment(app, sketch) else { return None };
|
||||
let FeatureKind::Sketch { sketch, .. } = &app.session.doc.feature(sketch)?.kind else { return None };
|
||||
Some((plane, sketch))
|
||||
}
|
||||
|
||||
pub fn guide_lines(app: &SolveApp) -> Vec<Vec<Vec3>> {
|
||||
let Some((plane, sketch)) = lost_guide(app) else { return Vec::new() };
|
||||
(0..sketch.curves.len())
|
||||
.flat_map(|index| sketch.segs(index))
|
||||
.map(|segment| segment.polyline(0.05).into_iter().map(|point| plane.to_world(point)).collect())
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::doc::{FaceAttachmentStatus, FeatureKind, PlaneRef};
|
||||
|
||||
fn picked_sketch() -> (Harness, u64) {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.session.auto_project = false;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"BaseBox","name":"Original"})).unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[0,0,20],"body_name":"ControlBox","name":"Control"})).unwrap();
|
||||
h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":false})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
h.step(&json!({"start":"sketch.create"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[3,4,10]}})).unwrap();
|
||||
h.settle();
|
||||
let sketch = h.app.session.active_sketch.expect("actual picked face creates the sketch");
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &h.app.session.doc.feature(sketch).unwrap().kind else {
|
||||
panic!("picked face must create a certified attachment");
|
||||
};
|
||||
assert_eq!(anchor.target.body, "BaseBox");
|
||||
assert!(anchor.path.is_empty());
|
||||
h.app.run("parameters.add", json!({"name":"badge_diameter","expression":"2 mm"})).unwrap();
|
||||
h.step(&json!({"start":"sketch.circle.center"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[5,5,10]}})).unwrap();
|
||||
h.step(&json!({"move":{"world":[7,5,10]}})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"badge_diameter"})).unwrap();
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
h.app.finish_sketch();
|
||||
h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":true})).unwrap();
|
||||
assert!(!h.app.session.doc.sketch(sketch).unwrap().constraints.is_empty());
|
||||
(h, sketch)
|
||||
}
|
||||
|
||||
fn raw_sketch(h: &Harness, id: u64) -> Value {
|
||||
serde_json::to_value(h.app.session.doc.sketch(id).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_picked_face_and_noop_edit_preserve_exact_anchor_and_dimensions() {
|
||||
let (mut h, sketch) = picked_sketch();
|
||||
let before = serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap();
|
||||
h.app.edit_sketch(sketch);
|
||||
h.settle();
|
||||
h.app.finish_sketch();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap(), before);
|
||||
let path = h.dir.join("picked-face.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap(), before);
|
||||
assert!(matches!(h.app.session.doc.sketch_face_attachment(sketch, &h.app.session.model), FaceAttachmentStatus::Healthy { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_attachment_ghost_rebind_preview_cancel_apply_and_undo_preserve_sketch() {
|
||||
let (mut h, sketch) = picked_sketch();
|
||||
h.app.run("solid.extrude", json!({"sketch":sketch,"distance":2,"operation":"new_body","body_name":"Boss"})).unwrap();
|
||||
h.app.run("timeline.suppress", json!({"feature":"Original","suppressed":true})).unwrap();
|
||||
assert!(h.app.session.model.result(sketch).unwrap().error.is_some());
|
||||
assert!(h.app.session.world_state().body("Boss").is_none(), "lost attachment cannot produce a Boss on ControlBox");
|
||||
assert!(h.app.session.world_state().sketch(sketch).is_none(), "saved repair guides are not solved sketch geometry");
|
||||
let raw = raw_sketch(&h, sketch);
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let redo = h.app.session.redo.len();
|
||||
h.app.edit_sketch(sketch);
|
||||
h.frames(4);
|
||||
assert_eq!(h.app.tree.redefine, Some(sketch));
|
||||
assert!(h.widget("Face attachment lost").is_some());
|
||||
let guides = crate::sketch_attachment_ui::guide_lines(&h.app);
|
||||
assert!(!guides.is_empty());
|
||||
assert!(guides.iter().flatten().all(|point| (point.z - 10.0).abs() < 1e-8));
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":false})).unwrap();
|
||||
h.app.session.selection.clear();
|
||||
h.frames(4);
|
||||
h.step(&json!({"click":{"world":[5,5,10]}})).unwrap();
|
||||
assert!(h.app.session.selection.is_empty(), "saved lost-plane guides must not be selectable geometry");
|
||||
assert!(h.app.session.active_sketch.is_none());
|
||||
h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":true})).unwrap();
|
||||
h.frames(4);
|
||||
h.step(&json!({"click":{"world":[3,4,30]}})).unwrap();
|
||||
assert!(matches!(h.app.session.selection.first(), Some(solvecraft_engine::Sel::Face { body, .. }) if body=="ControlBox"));
|
||||
h.frames(2);
|
||||
h.step(&json!({"widget":"Reattach selected face"})).unwrap();
|
||||
let commands = crate::sketch_attachment_ui::commands(&h.app)
|
||||
.unwrap_or_else(|| panic!("repair draft {:?}, selection {:?}", h.app.tree.redefine_draft, h.app.session.selection));
|
||||
assert_eq!(commands[0].1["plane"]["body"], "ControlBox");
|
||||
assert!(commands[0].1["plane"]["index"].as_u64().is_some());
|
||||
let preview = h.app.session.preview_visible_world(&commands).unwrap();
|
||||
let boss = preview.after.body("Boss").unwrap().mesh().bounds();
|
||||
assert!((boss.min.z - 30.0).abs() < 1e-7 && (boss.max.z - 32.0).abs() < 1e-7);
|
||||
assert_eq!(h.app.session.doc.to_json(), before, "preview is scratch only");
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.redo.len(), redo);
|
||||
assert!(crate::sketch_attachment_ui::guide_lines(&h.app).is_empty());
|
||||
|
||||
crate::browser::redefine_sketch(&mut h.app, sketch);
|
||||
h.frames(4);
|
||||
h.step(&json!({"widget":"Reattach selected face"})).unwrap();
|
||||
h.frames(8);
|
||||
h.step(&json!({"widget":"Apply"})).unwrap();
|
||||
assert!(h.app.tree.redefine.is_none(), "{:?}", h.app.tree.redefine_draft);
|
||||
assert_eq!(raw_sketch(&h, sketch), raw, "curves, dimensions and expressions remain exact");
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
assert!(h.app.session.model.result(sketch).unwrap().error.is_none());
|
||||
let boss = h.app.session.world_state().body("Boss").unwrap().mesh().bounds();
|
||||
assert!((boss.min.z - 30.0).abs() < 1e-7 && (boss.max.z - 32.0).abs() < 1e-7);
|
||||
let repaired = h.app.session.doc.to_json();
|
||||
let path = h.dir.join("rebound-face.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), repaired);
|
||||
assert_eq!(raw_sketch(&h, sketch), raw);
|
||||
assert!(h.app.session.model.result(sketch).unwrap().error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_drafts_leave_on_document_replacement_but_survive_failed_open() {
|
||||
let (mut h, sketch) = picked_sketch();
|
||||
crate::browser::redefine_sketch(&mut h.app, sketch);
|
||||
h.frames(2);
|
||||
h.step(&json!({"widget":"Use XZ plane"})).unwrap();
|
||||
assert!(crate::sketch_attachment_ui::commands(&h.app).is_some());
|
||||
assert!(h.app.run("doc.open", json!({"path":h.dir.join("missing.solvecraft")})).is_err());
|
||||
assert!(crate::sketch_attachment_ui::commands(&h.app).is_some());
|
||||
h.app.run("file.new", json!({})).unwrap();
|
||||
assert!(h.app.tree.redefine.is_none());
|
||||
assert!(h.app.tree.redefine_draft.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_modified_face_offers_explicit_fixed_plane_without_live_links() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Modified"})).unwrap();
|
||||
h.app.run("solid.fillet", json!({"edges":[[0,0,5]],"radius":1})).unwrap();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
h.step(&json!({"start":"sketch.create"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[5,5,10]}})).unwrap();
|
||||
assert!(h.app.session.active_sketch.is_none());
|
||||
assert!(h.app.dialog.as_ref().unwrap().error.is_some());
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
h.frames(4);
|
||||
assert!(h.widget("Use fixed plane").is_some());
|
||||
h.step(&json!({"widget":"Use fixed plane"})).unwrap();
|
||||
let sketch = h.app.session.active_sketch.expect("explicit fixed choice succeeds for the actual planar surface");
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch: geometry } = &h.app.session.doc.feature(sketch).unwrap().kind else {
|
||||
panic!("fixed choice must never create a durable-looking Face reference");
|
||||
};
|
||||
assert!((plane.origin.z - 10.0).abs() < 1e-7);
|
||||
assert!(geometry.links.is_empty(), "a fixed snapshot cannot carry live source projections");
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
fn occurrence_click(h: &mut Harness, path: &[u64], control: &str) {
|
||||
h.step(&json!({"click":{"occurrence":path,"control":control}})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_nested_second_placement_face_pick_keeps_target_and_author_paths() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.session.auto_project = false;
|
||||
let parent = h.app.run("component.create", json!({"name":"Parent"})).unwrap();
|
||||
let parent_component = parent["component"].as_u64().unwrap();
|
||||
let first_parent = parent["occurrence"].as_u64().unwrap();
|
||||
let child = h.app.run("component.create", json!({"name":"Child","parent":parent_component,"parent_path":[first_parent]})).unwrap();
|
||||
let child_component = child["component"].as_u64().unwrap();
|
||||
let child_occurrence = child["occurrence"].as_u64().unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"NestedBase"})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
let second_parent =
|
||||
h.app.run("occurrence.copy", json!({"occurrence":first_parent,"translate":[40,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.frames(4);
|
||||
let path = vec![second_parent, child_occurrence];
|
||||
if !crate::browser::drawn_occurrences().iter().any(|(drawn, _, _, _)| drawn == &path) {
|
||||
occurrence_click(&mut h, &[second_parent], "fold");
|
||||
}
|
||||
occurrence_click(&mut h, &path, "activate");
|
||||
assert_eq!(h.app.session.active_component, child_component);
|
||||
assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&path));
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
h.step(&json!({"start":"sketch.create"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[43,4,10]}})).unwrap();
|
||||
let sketch = h.app.session.active_sketch.expect("actual second nested face pick");
|
||||
let kind = h.app.session.doc.feature(sketch).unwrap().kind.clone();
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &kind else {
|
||||
panic!("strict face attachment");
|
||||
};
|
||||
assert_eq!(anchor.target.body, "NestedBase");
|
||||
assert_eq!(anchor.path, path);
|
||||
assert_eq!(anchor.author_path, path);
|
||||
assert_eq!(anchor.component, child_component);
|
||||
let FaceAttachmentStatus::Healthy { plane, .. } = h.app.session.placed_document().sketch_face_attachment(sketch, &h.app.session.model) else {
|
||||
panic!("selected saved placement resolves");
|
||||
};
|
||||
assert!((plane.origin.x - 40.0).abs() < 1e-7 && (plane.origin.z - 10.0).abs() < 1e-7);
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[1,1],"p1":[3,3]})).unwrap();
|
||||
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":2})).unwrap();
|
||||
h.app.finish_sketch();
|
||||
let kind = h.app.session.doc.feature(sketch).unwrap().kind.clone();
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
h.app.edit_sketch(sketch);
|
||||
assert_eq!(h.app.session.active_component, child_component);
|
||||
assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&path));
|
||||
h.app.finish_sketch();
|
||||
assert_eq!(h.app.session.doc.feature(sketch).unwrap().kind, kind);
|
||||
let file = h.dir.join("nested-face.solvecraft");
|
||||
h.app.run("file.save", json!({"path":file})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":file})).unwrap();
|
||||
assert_eq!(h.app.session.doc.feature(sketch).unwrap().kind, kind);
|
||||
h.app.run("selection.delete", json!({"occurrences":[second_parent]})).unwrap();
|
||||
assert!(h.app.session.model.result(sketch).unwrap().error.is_some());
|
||||
let FaceAttachmentStatus::Lost { repair_plane, .. } = h.app.session.placed_document().sketch_face_attachment(sketch, &h.app.session.model) else {
|
||||
panic!("deleted second placement must not substitute first");
|
||||
};
|
||||
assert!(repair_plane.is_none(), "a missing saved author placement cannot be drawn at its sibling");
|
||||
h.app.edit_sketch(sketch);
|
||||
h.frames(4);
|
||||
assert!(crate::sketch_attachment_ui::guide_lines(&h.app).is_empty());
|
||||
assert_eq!(h.app.tree.redefine, Some(sketch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_anchor_saved_fixed_conversion_preserves_rotated_second_author_plane() {
|
||||
use solvecraft_engine::doc::{EmbossFace, apply_point, apply_vector};
|
||||
use solvecraft_engine::geom::Plane;
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
let mut h = Harness::new();
|
||||
h.app.session.auto_project = false;
|
||||
let created = h.app.run("component.create", json!({"name":"Author"})).unwrap();
|
||||
let component = created["component"].as_u64().unwrap();
|
||||
let first = created["occurrence"].as_u64().unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"})).unwrap();
|
||||
let second = h.app.run("occurrence.copy", json!({"occurrence":first,"translate":[40,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"axis":[0,1,0],"angle":"90 deg","capture":true})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":component,"path":[second],"occurrence":second})).unwrap();
|
||||
let doc = h.app.session.placed_document();
|
||||
let body_name = doc.body_frames_with_source(&h.app.session.model.state()).into_iter().find(|(_, _, _, path, _)| path == &[second]).unwrap().0;
|
||||
let world = h.app.session.world_state();
|
||||
let face = world
|
||||
.body(&body_name)
|
||||
.unwrap()
|
||||
.body
|
||||
.faces(0.01)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|face| world.body(&body_name).unwrap().body.face_plane(face.index).is_some_and(|plane| plane.normal().dot(Vec3::X) > 0.999))
|
||||
.unwrap();
|
||||
let id = h
|
||||
.app
|
||||
.run("sketch.create", json!({"plane":{"body":body_name,"index":face.index,"face":face.centroid,"path":[second]},"project_edges":false}))
|
||||
.unwrap()["sketch"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[1,1],"p1":[3,3]})).unwrap();
|
||||
h.app.finish_sketch();
|
||||
// Exercise a durable anchor under both public plane wrappers, as imported
|
||||
// documents can contain these without an interactive wrapper editor.
|
||||
let mut document = (*h.app.session.doc).clone();
|
||||
let FeatureKind::Sketch { plane, .. } = &mut document.feature_mut(id).unwrap().kind else {
|
||||
panic!("sketch");
|
||||
};
|
||||
*plane = PlaneRef::AtAngle {
|
||||
base: Box::new(PlaneRef::Offset { base: Box::new(plane.clone()), distance: "2 mm".into() }),
|
||||
axis_origin: Vec3::ZERO,
|
||||
axis_dir: Vec3::X,
|
||||
angle: "15 deg".into(),
|
||||
};
|
||||
h.app.session = solvecraft_engine::Session::new(document);
|
||||
let raw = h.app.session.doc.sketch(id).unwrap().clone();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let FaceAttachmentStatus::Healthy { plane: expected, .. } = h.app.session.placed_document().sketch_face_attachment(id, &h.app.session.model)
|
||||
else {
|
||||
panic!("wrapped anchor must resolve");
|
||||
};
|
||||
crate::browser::redefine_sketch(&mut h.app, id);
|
||||
h.frames(4);
|
||||
assert_eq!(h.app.tree.redefine_draft.as_ref().unwrap().author_path, Some(vec![second]));
|
||||
h.step(&json!({"widget":"Use saved fixed plane"})).unwrap();
|
||||
let commands = crate::sketch_attachment_ui::commands(&h.app).unwrap();
|
||||
let preview = h.app.session.preview_visible_world(&commands).unwrap();
|
||||
// The shared sketch's primary world entry belongs to the untransformed
|
||||
// first placement. Verify that local plane at the specifically chosen
|
||||
// second placement, rather than treating the primary entry as that path.
|
||||
let frame = EmbossFace::component_frame(&h.app.session.placed_document(), component, &[second]).unwrap();
|
||||
let selected_plane =
|
||||
|local: Plane| Plane::new(apply_point(&frame, local.origin), apply_vector(&frame, local.x), apply_vector(&frame, local.y)).unwrap();
|
||||
let shown = selected_plane(preview.after.sketch(id).unwrap().plane);
|
||||
assert!(
|
||||
shown.origin.dist(expected.origin) < 1e-7,
|
||||
"expected {expected:?}, shown {shown:?}, primary {:?}, commands {commands:?}",
|
||||
preview.after.sketch(id).unwrap().plane
|
||||
);
|
||||
assert!(shown.x.dist(expected.x) < 1e-7 && shown.y.dist(expected.y) < 1e-7);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
h.frames(6);
|
||||
h.step(&json!({"widget":"Apply"})).unwrap();
|
||||
assert!(h.app.tree.redefine.is_none());
|
||||
assert_eq!(h.app.session.doc.sketch(id).unwrap(), &raw);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let shown = selected_plane(h.app.session.model.state().sketch(id).unwrap().plane);
|
||||
assert!(shown.origin.dist(expected.origin) < 1e-7);
|
||||
assert!(shown.x.dist(expected.x) < 1e-7 && shown.y.dist(expected.y) < 1e-7);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use super::*;
|
||||
use solvecraft_engine::sketch::CurveKind;
|
||||
|
||||
#[test]
|
||||
fn clicked_periodic_fit_closes_at_start_and_right_click_keeps_natural() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.settle();
|
||||
h.step(&json!({"start":"sketch.spline.fit_point"})).unwrap();
|
||||
for point in [[2, 2, 0], [8, 2, 0], [9, 7, 0], [1, 6, 0]] {
|
||||
h.step(&json!({"click":{"world":point}})).unwrap();
|
||||
}
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
assert!(h.app.session.model.state().sketch(sid).unwrap().sketch.curves.is_empty());
|
||||
h.step(&json!({"move":{"world":[2,2,0]}})).unwrap();
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().hover.as_ref().unwrap().1.as_deref(), Some("Close smooth loop"));
|
||||
h.step(&json!({"click":{"world":[2,2,0]}})).unwrap();
|
||||
let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone();
|
||||
assert_eq!(sk.curves.len(), 1, "{:?}", h.app.status);
|
||||
assert!(matches!(sk.curves[0].kind, CurveKind::Spline { periodic: true, .. }));
|
||||
sk.validate_periodic_splines().unwrap();
|
||||
assert_eq!(solvecraft_engine::sketch::find_profiles(&sk).len(), 1);
|
||||
assert!(h.app.tool.as_ref().unwrap().pts.is_empty());
|
||||
for point in [[15, 2, 0], [20, 2, 0], [20, 6, 0]] {
|
||||
h.step(&json!({"click":{"world":point}})).unwrap();
|
||||
}
|
||||
h.step(&json!({"click":{"world":[20,6,0]},"button":"right"})).unwrap();
|
||||
let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone();
|
||||
assert_eq!(sk.curves.len(), 2);
|
||||
assert!(matches!(sk.curves[1].kind, CurveKind::Spline { periodic: false, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicked_ellipse_offset_then_closed_offset_preserves_exact_distance_and_undo() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})).unwrap();
|
||||
h.settle();
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
h.step(&json!({"start":"sketch.offset"})).unwrap();
|
||||
// Major tip is also a source handle: Offset must choose the actual curve.
|
||||
h.step(&json!({"click":{"world":[10,0,0]}})).unwrap();
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().picks, vec!["e1"]);
|
||||
h.step(&json!({"click":{"world":[11,0,0]}})).unwrap();
|
||||
let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone();
|
||||
assert_eq!(sk.freeform_offsets.len(), 1, "{:?}", h.app.status);
|
||||
assert!((sk.freeform_offsets[0].distance + 1.0).abs() < 1e-4);
|
||||
let first = sk.freeform_offsets[0].output.clone();
|
||||
let saved = h.app.session.doc.to_json();
|
||||
h.step(&json!({"click":{"world":[11,0,0]}})).unwrap();
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().picks, vec![first]);
|
||||
h.step(&json!({"click":{"world":[11.5,0,0]}})).unwrap();
|
||||
let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone();
|
||||
assert_eq!(sk.freeform_offsets.len(), 2, "{:?}", h.app.status);
|
||||
assert!((sk.freeform_offsets[1].distance + 0.5).abs() < 1e-4);
|
||||
assert!(sk.freeform_offsets.iter().all(|r| r.certificate == 2));
|
||||
assert_eq!(solvecraft_engine::sketch::find_profiles(&sk).len(), 3);
|
||||
let last = sk.curve_index(&sk.freeform_offsets[1].output).unwrap();
|
||||
assert!(sk.exact_segs(last).unwrap()[0].point_at(0.0).dist(Vec2::new(11.5, 0.0)) < 1e-4);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicked_extend_endpoint_reaches_exact_ellipse_and_preserves_support() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.ui.show_origin = true;
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.ellipse", json!({"center":[20,0],"major":[30,0],"minor_radius":5})).unwrap();
|
||||
h.app.run("sketch.constraint.fix", json!({"entity":"e1"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,3],[5,3]],"connect":false,"infer":false})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"start":"sketch.extend"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[5,3,0]}})).unwrap();
|
||||
let st = h.app.session.world_state();
|
||||
let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
let end = ss.sketch.point(ss.sketch.resolve_point("l1.end").unwrap()).unwrap();
|
||||
assert!(end.dist(Vec2::new(12.0, 3.0)) < 1e-7, "clicked endpoint must extend: {end:?}, {:?}", h.app.status);
|
||||
assert_eq!(ss.sketch.point(ss.sketch.resolve_point("l1.start").unwrap()), Some(Vec2::new(0.0, 3.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derived_offset_context_action_releases_geometry_and_undo_restores() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})).unwrap();
|
||||
let out = h.app.run("sketch.offset", json!({"curves":["e1"],"distance":-1,"chain":false})).unwrap();
|
||||
let id = out["curves"][0].as_str().unwrap().to_string();
|
||||
h.app.session.selection = vec![solvecraft_engine::Sel::SketchCurve { id: id.clone() }];
|
||||
let items = crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport);
|
||||
let item = items.iter().find(|item| item.id == "sketch.offset.break").unwrap();
|
||||
assert_eq!(item.params, Some(json!({"curve":id})));
|
||||
crate::context_menu::run_item(&mut h.app, item, egui::Pos2::ZERO);
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
assert!(h.app.session.model.state().sketch(sid).unwrap().sketch.freeform_offsets.is_empty());
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.model.state().sketch(sid).unwrap().sketch.freeform_offsets.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicked_origin_corner_fillets_and_chamfers_with_origin_visible() {
|
||||
for command in ["sketch.fillet", "sketch.chamfer.equal_distance", "sketch.chamfer.distance_angle", "sketch.chamfer.two_distance"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.ui.show_origin = true;
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,30]})).unwrap();
|
||||
h.frames(2);
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap());
|
||||
let cursor = proj.to_screen(Vec3::ZERO).unwrap();
|
||||
// The ordinary snap still finds the fixed origin; the corner tool must instead
|
||||
// reach the rectangle's overlapping endpoint.
|
||||
assert_eq!(sketch_point_at(&h.app, &proj, cursor).unwrap().1.as_deref(), Some("origin"));
|
||||
h.step(&json!({"start":command})).unwrap();
|
||||
h.step(&json!({"move":{"world":[0,0,0]}})).unwrap();
|
||||
let point = h.app.tool.as_ref().unwrap().hover.as_ref().unwrap().1.as_deref().unwrap();
|
||||
assert_ne!(point, "origin", "the actual shared endpoint must be highlighted");
|
||||
h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
|
||||
let st = h.app.session.world_state();
|
||||
let ss = st.sketch(sid).unwrap();
|
||||
assert_eq!(ss.sketch.point(0), Some(Vec2::ZERO));
|
||||
assert!(ss.sketch.points[0].fixed);
|
||||
assert_eq!(ss.sketch.curves.len(), 5, "{command} must actually modify the geometry; status: {:?}", h.app.status);
|
||||
if command == "sketch.fillet" {
|
||||
assert!(ss.sketch.curves.iter().any(|c| matches!(c.kind, CurveKind::Arc { .. })));
|
||||
} else {
|
||||
assert!(ss.sketch.curves.iter().all(|c| matches!(c.kind, CurveKind::Line { .. })));
|
||||
}
|
||||
let expected = if command == "sketch.fillet" { 600.0 - 25.0 * (1.0 - std::f64::consts::PI / 4.0) } else { 587.5 };
|
||||
assert_eq!(ss.profiles.len(), 1);
|
||||
assert!((ss.profiles[0].area - expected).abs() < 1e-6, "{command}: {}", ss.profiles[0].area);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
let st = h.app.session.world_state();
|
||||
let ss = st.sketch(sid).unwrap();
|
||||
assert_eq!(ss.sketch.curves.len(), 4);
|
||||
assert!((ss.profiles[0].area - 600.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_drawing_keeps_origin_snap_and_real_origin_corners_work() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.ui.show_origin = true;
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.step(&json!({"start":"sketch.line"})).unwrap();
|
||||
h.step(&json!({"move":{"world":[0,0,0]}})).unwrap();
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().hover.as_ref().unwrap().1.as_deref(), Some("origin"));
|
||||
h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().pts[0].1.as_deref(), Some("origin"));
|
||||
h.app.tool = None;
|
||||
h.app.run("sketch.line", json!({"points":["origin",[20,0],[20,20],[0,20],"origin"]})).unwrap();
|
||||
h.step(&json!({"start":"sketch.fillet"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
|
||||
let st = h.app.session.world_state();
|
||||
let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
assert_eq!(ss.sketch.point(0), Some(Vec2::ZERO));
|
||||
assert!(ss.sketch.curves.iter().any(|c| matches!(c.kind, CurveKind::Arc { .. })));
|
||||
assert_eq!(ss.profiles.len(), 1);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ pub fn boxes_for(cmd: &str, n: usize) -> Vec<DimBox> {
|
||||
|
||||
/// A locked box's value (mm or radians).
|
||||
fn locked(app: &SolveApp, t: &Tool, label: &str) -> Option<f64> {
|
||||
let b = t.dims.iter().find(|b| b.label == label && b.locked)?;
|
||||
let b = t.dims.iter().chain(&t.dim_history).find(|b| b.label == label && b.locked)?;
|
||||
app.session.doc.eval(&b.value, b.kind).ok().filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
@@ -181,29 +181,71 @@ fn fmt(kind: ValueKind, v: f64) -> String {
|
||||
/// Make the tool's boxes match its stage (new boxes when a point was placed).
|
||||
pub fn sync(t: &mut Tool) {
|
||||
if t.dims_stage != t.pts.len() {
|
||||
if t.dims_stage == 1
|
||||
&& t.pts.len() == 2
|
||||
&& matches!(t.cmd, "sketch.arc.center_point" | "sketch.slot.center_to_center" | "sketch.slot.overall")
|
||||
{
|
||||
t.dim_history.extend(t.dims.iter().filter(|dimension| dimension.locked).cloned());
|
||||
} else if t.pts.len() <= t.dims_stage || t.cmd == "sketch.line" {
|
||||
t.dim_history.clear();
|
||||
}
|
||||
t.dims = boxes_for(t.cmd, t.pts.len());
|
||||
t.dims_stage = t.pts.len();
|
||||
t.dims_focus = !t.dims.is_empty();
|
||||
}
|
||||
}
|
||||
|
||||
/// The dimension constraints the locked values ask for after the shape made `curves`:
|
||||
/// (entities, type, value).
|
||||
pub fn constraints(t: &Tool, curves: &[String]) -> Vec<(Vec<String>, &'static str, String)> {
|
||||
let mut out = Vec::new();
|
||||
for b in t.dims.iter().filter(|b| b.locked) {
|
||||
let target = match (t.cmd, b.label) {
|
||||
("sketch.line", "Length") | ("sketch.rectangle.two_point" | "sketch.rectangle.center", "Width") => curves.first().map(|c| (c, "length")),
|
||||
("sketch.rectangle.two_point" | "sketch.rectangle.center", "Height") => curves.get(1).map(|c| (c, "length")),
|
||||
("sketch.circle.center", "Diameter") => curves.first().map(|c| (c, "diameter")),
|
||||
("sketch.arc.center_point", "Radius") => curves.first().map(|c| (c, "radius")),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((c, ty)) = target {
|
||||
out.push((vec![c.clone()], ty, b.value.clone()));
|
||||
/// Clear a shape's input immediately, including when another first point arrives before redraw.
|
||||
pub fn reset(t: &mut Tool) {
|
||||
t.dims.clear();
|
||||
t.dim_history.clear();
|
||||
t.dims_stage = usize::MAX;
|
||||
t.dims_focus = false;
|
||||
}
|
||||
|
||||
pub fn has_locked(t: &Tool, label: &str) -> bool {
|
||||
t.dims.iter().chain(&t.dim_history).any(|dimension| dimension.locked && dimension.label == label)
|
||||
}
|
||||
|
||||
/// Original expressions, rather than sampled numbers, become the shape's permanent dimensions.
|
||||
pub fn dimensions(t: &Tool) -> Value {
|
||||
let values = t
|
||||
.dim_history
|
||||
.iter()
|
||||
.chain(&t.dims)
|
||||
.filter(|dimension| dimension.locked && dimension.label != "Sides")
|
||||
.map(|dimension| (dimension.label.to_ascii_lowercase(), json!(dimension.value)))
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
Value::Object(values)
|
||||
}
|
||||
|
||||
/// Invalid values stay in the current field, so users can correct them before another stage.
|
||||
pub fn validate(app: &SolveApp, t: &Tool) -> Result<(), String> {
|
||||
for dimension in t.dim_history.iter().chain(&t.dims).filter(|dimension| dimension.locked) {
|
||||
if dimension.value.len() > 4096 {
|
||||
return Err(format!("{} expression is too long", dimension.label));
|
||||
}
|
||||
let value = app.session.doc.eval(&dimension.value, dimension.kind).map_err(|error| format!("{}: {error}", dimension.label))?;
|
||||
if !value.is_finite() || value.abs() > 1e9 {
|
||||
return Err(format!("{} must be finite and within the model's size limits", dimension.label));
|
||||
}
|
||||
match dimension.label {
|
||||
"Angle" => {}
|
||||
"Sweep" if value.abs() > 1e-9 && value.abs() < std::f64::consts::TAU - 1e-9 => {}
|
||||
"Sweep" => return Err("Sweep must lie strictly between -360 and 360 degrees, and cannot be zero".into()),
|
||||
"Sides" if (3.0..=200.0).contains(&value) && value.fract() == 0.0 => {}
|
||||
"Sides" => return Err("Sides must be an integer from 3 to 200".into()),
|
||||
_ if value > 1e-9 => {}
|
||||
_ => return Err(format!("{} must be positive", dimension.label)),
|
||||
}
|
||||
}
|
||||
out
|
||||
if t.cmd == "sketch.slot.overall"
|
||||
&& let (Some(length), Some(width)) = (locked(app, t, "Length"), locked(app, t, "Width"))
|
||||
&& length <= width
|
||||
{
|
||||
return Err("Overall slot Length must exceed Width".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A locked box value by label (for the polygon side count).
|
||||
@@ -240,6 +282,30 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
|
||||
let px = 2.0 * app.cam.half_height() / f64::from(proj.rect.height().max(1.0));
|
||||
let lv = live(t, e, px);
|
||||
let focus = std::mem::take(&mut t.dims_focus);
|
||||
// Egui processes ordinary Tab navigation before these floating text edits are drawn.
|
||||
// Remember the box that owned the keyboard and restore a focus within this tool after
|
||||
// drawing, so the last dimension wraps to the first rather than reaching the panels.
|
||||
let tab = ui.input(|input| {
|
||||
input.raw.events.iter().find_map(|event| match event {
|
||||
egui::Event::Key { key: egui::Key::Tab, pressed: true, modifiers, .. } if !modifiers.ctrl && !modifiers.alt && !modifiers.command => {
|
||||
Some(modifiers.shift)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let source = tab.and_then(|_| {
|
||||
ui.ctx().memory(|memory| {
|
||||
(0..t.dims.len())
|
||||
.find(|i| memory.had_focus_last_frame(egui::Id::new(("sc_dim", i))))
|
||||
.or_else(|| (0..t.dims.len()).find(|i| memory.has_focus(egui::Id::new(("sc_dim", i)))))
|
||||
})
|
||||
});
|
||||
if source.is_some() {
|
||||
// Also cancel navigation on the first focused frame, before the text edit's focus
|
||||
// filter has been installed (e.g. immediately repeated keyboard input).
|
||||
ui.ctx().memory_mut(|memory| memory.move_focus(egui::FocusDirection::None));
|
||||
}
|
||||
let mut completed = false;
|
||||
let mut any_focused = false;
|
||||
for (i, b) in t.dims.iter_mut().enumerate() {
|
||||
let Some((v, at)) = lv.get(i).copied() else { continue };
|
||||
@@ -260,9 +326,21 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
|
||||
egui::Area::new(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let fill = if b.locked { tk.accent_soft } else { tk.panel };
|
||||
egui::Frame::popup(ui.style()).fill(fill).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(66.0)).on_hover_text(b.label);
|
||||
crate::params_dialog::complete(ui, &r, &mut b.value);
|
||||
if r.changed() {
|
||||
let r = ui
|
||||
// The drawing tool handles Enter. Keep the text edit focused when a
|
||||
// rejected value needs correction, instead of surrendering the keyboard.
|
||||
.add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(66.0).return_key(None).event_filter(egui::EventFilter {
|
||||
tab: true,
|
||||
horizontal_arrows: true,
|
||||
vertical_arrows: true,
|
||||
..Default::default()
|
||||
}))
|
||||
.on_hover_text(b.label);
|
||||
// A completion gets first use of forward Tab. Shift+Tab always moves back;
|
||||
// the box reached by egui's generic traversal must not complete its text.
|
||||
let changed = (source.is_none() || (source == Some(i) && tab != Some(true))) && crate::params_dialog::complete(ui, &r, &mut b.value);
|
||||
completed |= changed && source == Some(i);
|
||||
if r.changed() || changed {
|
||||
b.locked = true;
|
||||
}
|
||||
if focus && i == 0 {
|
||||
@@ -272,12 +350,30 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
|
||||
});
|
||||
});
|
||||
}
|
||||
if let Some(source) = source
|
||||
&& !completed
|
||||
{
|
||||
let next = if tab == Some(true) { (source + t.dims.len() - 1) % t.dims.len() } else { (source + 1) % t.dims.len() };
|
||||
let id = egui::Id::new(("sc_dim", next));
|
||||
if let Some(dimension) = t.dims.get(next) {
|
||||
let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(
|
||||
egui::text::CCursor::new(0),
|
||||
egui::text::CCursor::new(dimension.value.chars().count()),
|
||||
)));
|
||||
state.store(ui.ctx(), id);
|
||||
}
|
||||
ui.ctx().memory_mut(|memory| memory.request_focus(id));
|
||||
}
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let nothing = ui.ctx().memory(|m| m.focused().is_none());
|
||||
(enter && (any_focused || nothing)).then_some(e)
|
||||
}
|
||||
|
||||
/// JSON for a typed dimension.
|
||||
pub fn dimension_params(entities: &[String], ty: &str, value: &str) -> Value {
|
||||
json!({"entities": entities, "type": ty, "value": value})
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_dims_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_typed_intent_tests.rs"]
|
||||
mod typed_intent_tests;
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Real keyboard input must stay inside the dimensions of the shape being drawn.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::scenario::Harness;
|
||||
|
||||
fn drawing(command: &str) -> Harness {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
|
||||
h.step(&json!({"start": command})).unwrap();
|
||||
h.step(&json!({"click": {"world": [0, 0, 0]}})).unwrap();
|
||||
h.step(&json!({"move": {"world": [20, 10, 0]}})).unwrap();
|
||||
assert_focus(&h, 0);
|
||||
h
|
||||
}
|
||||
|
||||
fn assert_focus(h: &Harness, index: usize) {
|
||||
let expected = egui::Id::new(("sc_dim", index));
|
||||
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(expected), "focus must stay in inline dimension {index}");
|
||||
}
|
||||
|
||||
fn tab(h: &mut Harness, backwards: bool) {
|
||||
h.step(&json!({"key": "Tab", "shift": backwards})).unwrap();
|
||||
}
|
||||
|
||||
fn type_value(h: &mut Harness, text: &str) {
|
||||
h.step(&json!({"key": "A", "cmd": true})).unwrap();
|
||||
h.step(&json!({"text": text})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_cycle_rectangle_dimensions_indefinitely_and_enter_commits_values() {
|
||||
for command in ["sketch.rectangle.two_point", "sketch.rectangle.center"] {
|
||||
let mut h = drawing(command);
|
||||
type_value(&mut h, "30");
|
||||
tab(&mut h, false);
|
||||
assert_focus(&h, 1);
|
||||
type_value(&mut h, "12");
|
||||
for i in 0..8 {
|
||||
tab(&mut h, false);
|
||||
assert_focus(&h, i % 2);
|
||||
}
|
||||
for i in 0..8 {
|
||||
tab(&mut h, true);
|
||||
assert_focus(&h, i % 2);
|
||||
}
|
||||
// Revisited locked dimensions are selected, so another value replaces them directly.
|
||||
h.step(&json!({"text": "14"})).unwrap();
|
||||
tab(&mut h, true);
|
||||
assert_focus(&h, 0);
|
||||
h.step(&json!({"text": "32"})).unwrap();
|
||||
let dims = &h.app.tool.as_ref().unwrap().dims;
|
||||
assert_eq!(dims.iter().map(|d| d.value.as_str()).collect::<Vec<_>>(), ["32", "14"]);
|
||||
assert!(dims.iter().all(|d| d.locked));
|
||||
h.step(&json!({"key": "Enter"})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
assert_eq!(sketch.sketch.curves.iter().filter(|curve| !curve.construction).count(), 4);
|
||||
assert_eq!(sketch.profiles.len(), 1);
|
||||
assert!((sketch.profiles[0].area - 448.0).abs() < 1e-6, "{command}: {}", sketch.profiles[0].area);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_dimensions_wrap_both_directions_and_enter_uses_length_and_angle() {
|
||||
let mut h = drawing("sketch.line");
|
||||
type_value(&mut h, "10");
|
||||
tab(&mut h, false);
|
||||
type_value(&mut h, "45 deg");
|
||||
for i in 0..6 {
|
||||
tab(&mut h, false);
|
||||
assert_focus(&h, i % 2);
|
||||
}
|
||||
tab(&mut h, true);
|
||||
assert_focus(&h, 0);
|
||||
h.step(&json!({"key": "Enter"})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
assert_eq!(sketch.sketch.curves.len(), 1);
|
||||
let points: Vec<_> = sketch.sketch.points.iter().map(|point| point.pos).collect();
|
||||
assert!(points.iter().any(|point| (point.x - 10.0 / 2f64.sqrt()).abs() < 1e-6 && (point.y - 10.0 / 2f64.sqrt()).abs() < 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_dimension_keeps_focus_for_tab_and_shift_tab() {
|
||||
let mut h = drawing("sketch.circle.center");
|
||||
type_value(&mut h, "8");
|
||||
for backwards in [false, false, true, true, false] {
|
||||
tab(&mut h, backwards);
|
||||
assert_focus(&h, 0);
|
||||
}
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "8");
|
||||
h.step(&json!({"key": "Enter"})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
assert_eq!(sketch.profiles.len(), 1);
|
||||
assert!((sketch.profiles[0].area - std::f64::consts::PI * 16.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_tab_completes_expression_then_cycles_and_shift_tab_moves_back() {
|
||||
let mut h = drawing("sketch.rectangle.two_point");
|
||||
h.app.run("parameters.add", json!({"name": "width", "expression": "24 mm"})).unwrap();
|
||||
h.frames(2);
|
||||
type_value(&mut h, "wid");
|
||||
tab(&mut h, false);
|
||||
assert_focus(&h, 0);
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "width");
|
||||
tab(&mut h, false);
|
||||
assert_focus(&h, 1);
|
||||
type_value(&mut h, "5");
|
||||
tab(&mut h, true);
|
||||
assert_focus(&h, 0);
|
||||
type_value(&mut h, "wid");
|
||||
tab(&mut h, true);
|
||||
assert_focus(&h, 1);
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "wid", "Shift+Tab must navigate rather than accepting a completion");
|
||||
tab(&mut h, true);
|
||||
assert_focus(&h, 0);
|
||||
tab(&mut h, false);
|
||||
assert_focus(&h, 0);
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "width");
|
||||
h.step(&json!({"key": "Enter"})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
assert_eq!(sketch.profiles.len(), 1);
|
||||
assert!((sketch.profiles[0].area - 120.0).abs() < 1e-6);
|
||||
}
|
||||
@@ -373,6 +373,7 @@ pub fn tool_for(id: &str) -> Option<Tool> {
|
||||
hover: None,
|
||||
picks: Vec::new(),
|
||||
dims: Vec::new(),
|
||||
dim_history: Vec::new(),
|
||||
dims_stage: usize::MAX,
|
||||
dims_focus: false,
|
||||
})
|
||||
@@ -402,7 +403,7 @@ pub fn hint(id: &str) -> Option<String> {
|
||||
"sketch.slot.center_point" => "Slot: click the centre, an arc centre, then the width",
|
||||
"sketch.slot.arc_three_point" => "Arc slot: click start, a point on the arc, the end, then the width",
|
||||
"sketch.slot.arc_center" => "Arc slot: click the centre, the start, the end, then the width",
|
||||
"sketch.spline.fit_point" => "Spline: click fit points; right-click to finish",
|
||||
"sketch.spline.fit_point" => "Spline: click fit points; click the start to close smoothly, or right-click to finish",
|
||||
"sketch.spline.control_point" | "sketch.spline.control_point_5" => "Control point spline: click control points; right-click to finish",
|
||||
"sketch.text" => "Text: click where the text starts",
|
||||
"sketch.blend_curve" => "Blend curve: click the ends of two curves",
|
||||
@@ -458,6 +459,41 @@ fn sketch_entity(app: &SolveApp, hits: &[Hit]) -> Option<(String, bool)> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Corner tools accept endpoints shared by two editable curves. Pick among those endpoints
|
||||
/// directly so the origin, a circle centre or an overlapping loose point cannot hide a corner.
|
||||
pub(super) fn corner_point(app: &SolveApp, proj: &Proj, pos: Pos2, cmd: &str) -> Option<(Vec2, String)> {
|
||||
let st = app.session.world_state();
|
||||
let ss = st.sketch(active_sketch(app)?)?;
|
||||
let sk = &ss.sketch;
|
||||
sk.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(pi, point)| {
|
||||
let distance = proj.to_screen(ss.plane.to_world(point.pos))?.distance(pos);
|
||||
if distance >= 8.0 {
|
||||
return None;
|
||||
}
|
||||
let curves: Vec<_> = sk
|
||||
.curves
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(ci, c)| !sk.is_linked_curve(*ci) && c.kind.ends().is_some_and(|(a, b)| a == pi || b == pi))
|
||||
.take(3)
|
||||
.collect();
|
||||
if curves.len() != 2
|
||||
|| curves.iter().any(|(_, c)| {
|
||||
!matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. })
|
||||
&& (cmd != "sketch.fillet" || !matches!(c.kind, solvecraft_engine::sketch::CurveKind::Arc { .. }))
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((distance, point.pos, point.id.clone()))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, at, id)| (at, id))
|
||||
}
|
||||
|
||||
fn xy(p: Vec2) -> Value {
|
||||
json!([p.x, p.y])
|
||||
}
|
||||
@@ -510,6 +546,29 @@ fn round_nice(x: f64) -> f64 {
|
||||
}
|
||||
|
||||
/// A click with an extra tool.
|
||||
pub fn periodic_start_snap(app: &SolveApp, tool: &Tool, proj: &Proj, pos: Pos2) -> Option<Vec2> {
|
||||
if tool.cmd != "sketch.spline.fit_point" || tool.pts.len() < 3 || crate::drag_snap::suppressed() {
|
||||
return None;
|
||||
}
|
||||
let first = tool.pts.first()?.0;
|
||||
let mut distinct = Vec::new();
|
||||
for p in &tool.pts {
|
||||
if !distinct.contains(&p.0) {
|
||||
distinct.push(p.0);
|
||||
}
|
||||
if distinct.len() == 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if distinct.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let state = app.session.world_state();
|
||||
let plane = &state.sketch(app.session.active_sketch?)?.plane;
|
||||
let screen = proj.to_screen(plane.to_world(first))?;
|
||||
(screen.distance(pos) <= 8.0).then_some(first)
|
||||
}
|
||||
|
||||
pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
let Some(m) = mode(tool.cmd) else { return };
|
||||
let hits = pick(app, proj, pos);
|
||||
@@ -611,11 +670,27 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
}
|
||||
}
|
||||
Mode::CurveAt => {
|
||||
if let (Some((id, false)), Some((p, _))) = (sketch_entity(app, &hits), at) {
|
||||
let curve = active_sketch(app).and_then(|active| {
|
||||
hits.iter().find_map(|hit| match hit {
|
||||
Hit::SketchCurve { sketch, id, .. } if *sketch == active => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let (Some(id), Some((p, _))) = (curve, at) {
|
||||
let _ = app.run(cmd, json!({"curve": id, "at": xy(p)}));
|
||||
}
|
||||
}
|
||||
Mode::Points(n) => {
|
||||
if periodic_start_snap(app, tool, proj, pos).is_some() {
|
||||
// Only this deliberate click selects periodic interpolation. A
|
||||
// right-click finish and all previously saved splines stay natural.
|
||||
let params = json!({"points":tool.pts.iter().map(arg).collect::<Vec<_>>(),"periodic":true});
|
||||
if app.run(cmd, params).is_ok() {
|
||||
tool.pts.clear();
|
||||
crate::sketch_dims::reset(tool);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(p) = at else { return };
|
||||
tool.pts.push(p);
|
||||
if n > 0 && tool.pts.len() >= n {
|
||||
@@ -624,28 +699,37 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
}
|
||||
}
|
||||
Mode::Entities(n) => {
|
||||
if cmd == "sketch.offset" && !tool.picks.is_empty() {
|
||||
if let Some((p, _)) = at {
|
||||
offset_side(app, tool, p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some((id, is_point)) = sketch_entity(app, &hits) else { return };
|
||||
if cmd == "sketch.offset" {
|
||||
// First the curve, then the side point.
|
||||
if tool.picks.is_empty() && !is_point {
|
||||
tool.picks.push(id);
|
||||
} else if let Some((p, _)) = at {
|
||||
offset_side(app, tool, p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if cmd == "sketch.fillet" || cmd.starts_with("sketch.chamfer.") {
|
||||
if is_point {
|
||||
if let Some((_, id)) = corner_point(app, proj, pos, cmd) {
|
||||
run_corner(app, cmd, &id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if cmd == "sketch.offset" && !tool.picks.is_empty() {
|
||||
// Dense derived fit nodes must not snap a nearby side click back
|
||||
// onto the source, reducing a deliberate offset to zero.
|
||||
let state = app.session.world_state();
|
||||
let side = active_sketch(app).and_then(|sid| state.sketch(sid)).and_then(|ss| {
|
||||
let (o, d) = proj.ray(pos);
|
||||
ss.plane.intersect_ray(o, d).map(|p| ss.plane.to_local(p))
|
||||
});
|
||||
if let Some(p) = side {
|
||||
offset_side(app, tool, p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if cmd == "sketch.offset" {
|
||||
if let Some(sid) = active_sketch(app)
|
||||
&& let Some(id) = hits.iter().find_map(|hit| match hit {
|
||||
Hit::SketchCurve { sketch, id, .. } if *sketch == sid => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
tool.picks.push(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some((id, _)) = sketch_entity(app, &hits) else { return };
|
||||
if cmd == "sketch.centerline" {
|
||||
let _ = app.run(cmd, json!({"curves": [id]}));
|
||||
return;
|
||||
@@ -848,8 +932,14 @@ fn offset_side(app: &mut SolveApp, tool: &mut Tool, p: Vec2) -> bool {
|
||||
let st = app.session.world_state();
|
||||
let d = active_sketch(app)
|
||||
.and_then(|s| st.sketch(s))
|
||||
.and_then(|ss| ss.sketch.curve_index(&tool.picks[0]).and_then(|c| ss.sketch.shape(c)))
|
||||
.map(|sh| sh.dist(p))
|
||||
.and_then(|ss| {
|
||||
let curve = ss.sketch.curve_index(&tool.picks[0])?;
|
||||
if let Some(shape) = ss.sketch.shape(curve) {
|
||||
Some(shape.dist(p))
|
||||
} else {
|
||||
ss.sketch.exact_segs(curve)?.iter().map(|s| s.point_at(s.closest_param(p)).dist(p)).min_by(f64::total_cmp)
|
||||
}
|
||||
})
|
||||
.unwrap_or(0.0);
|
||||
if d > 1e-6 {
|
||||
let _ = app.run("sketch.offset", json!({"curves": [tool.picks[0]], "distance": d, "side": xy(p)}));
|
||||
@@ -1132,3 +1222,7 @@ fn text_entry(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
None => TEXT.with(|t| *t.borrow_mut() = Some((at, text))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_corner_ui_tests.rs"]
|
||||
mod corner_ui_tests;
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Keyboard-authored intent must survive stage transitions and later model edits.
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::sketch::{ConstraintKind, CurveKind};
|
||||
|
||||
fn start(command: &str) -> Harness {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.step(&json!({"start":command})).unwrap();
|
||||
h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
|
||||
h.step(&json!({"move":{"world":[20,0,0]}})).unwrap();
|
||||
h
|
||||
}
|
||||
fn type_value(h: &mut Harness, expression: &str) {
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":expression})).unwrap();
|
||||
}
|
||||
fn enter(h: &mut Harness) {
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
}
|
||||
fn constraints(h: &Harness) -> Vec<ConstraintKind> {
|
||||
h.app.session.world_state().sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch.constraints.iter().map(|c| c.kind.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_arc_radius_and_signed_sweep_survive_stages_parameter_edit_and_reopen() {
|
||||
let mut h = start("sketch.arc.center_point");
|
||||
h.app.run("parameters.add", json!({"name":"arc_radius","expression":"5 mm"})).unwrap();
|
||||
type_value(&mut h, "arc_radius");
|
||||
enter(&mut h);
|
||||
let tool = h.app.tool.as_ref().unwrap();
|
||||
assert_eq!(tool.pts.len(), 2);
|
||||
assert_eq!(tool.dim_history[0].value, "arc_radius");
|
||||
assert_eq!(tool.dims[0].label, "Sweep");
|
||||
type_value(&mut h, "-90 deg");
|
||||
enter(&mut h);
|
||||
assert!(h.app.tool.as_ref().unwrap().dim_history.is_empty());
|
||||
let kinds = constraints(&h);
|
||||
assert!(kinds.iter().any(|k| matches!(k, ConstraintKind::Radius{value,..} if (*value-5.0).abs()<1e-7)));
|
||||
assert!(kinds.iter().any(|k| matches!(k, ConstraintKind::ArcSweep{value,..} if (*value+std::f64::consts::FRAC_PI_2).abs()<1e-7)));
|
||||
h.app.run("parameters.change", json!({"name":"arc_radius","expression":"8 mm"})).unwrap();
|
||||
let path = h.dir.join("typed-arc.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let sketch = &state.sketches[0].sketch;
|
||||
let curve = &sketch.curves[0];
|
||||
assert!(curve.reversed, "negative typed sweep must remain clockwise");
|
||||
let CurveKind::Arc { c, a, .. } = curve.kind else { panic!("expected arc") };
|
||||
assert!((sketch.points[c].pos.dist(sketch.points[a].pos) - 8.0).abs() < 1e-6);
|
||||
assert!(sketch.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::ArcSweep{value,..} if value<0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_keep_typed_length_and_width_as_dimensions_and_start_fresh() {
|
||||
for command in ["sketch.slot.center_to_center", "sketch.slot.overall"] {
|
||||
let mut h = start(command);
|
||||
type_value(&mut h, "30");
|
||||
enter(&mut h);
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().dim_history[0].value, "30");
|
||||
h.step(&json!({"move":{"world":[15,5,0]}})).unwrap();
|
||||
type_value(&mut h, "6");
|
||||
enter(&mut h);
|
||||
let kinds = constraints(&h);
|
||||
assert!(kinds.iter().any(|k| matches!(k,ConstraintKind::Diameter{value,..} if (*value-6.0).abs()<1e-7)), "{command}");
|
||||
if command.ends_with("overall") {
|
||||
assert!(kinds.iter().any(|k| matches!(k,ConstraintKind::SlotOverallLength{value,..} if (*value-30.0).abs()<1e-7)));
|
||||
} else {
|
||||
assert!(kinds.iter().any(|k| matches!(k,ConstraintKind::Length{value,..} if (*value-30.0).abs()<1e-7)));
|
||||
}
|
||||
h.step(&json!({"click":{"world":[50,20,0]}})).unwrap();
|
||||
h.step(&json!({"move":{"world":[70,20,0]}})).unwrap();
|
||||
let tool = h.app.tool.as_ref().unwrap();
|
||||
assert!(tool.dim_history.is_empty());
|
||||
assert!(tool.dims.iter().all(|d| !d.locked));
|
||||
type_value(&mut h, "12");
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.tool.as_ref().unwrap().dim_history.is_empty());
|
||||
assert!(h.app.tool.as_ref().unwrap().pts.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_stage_values_remain_editable_without_partial_geometry() {
|
||||
let mut h = start("sketch.arc.center_point");
|
||||
type_value(&mut h, "-3");
|
||||
enter(&mut h);
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().pts.len(), 1);
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "-3");
|
||||
assert!(constraints(&h).is_empty());
|
||||
type_value(&mut h, "5");
|
||||
enter(&mut h);
|
||||
type_value(&mut h, "360 deg");
|
||||
enter(&mut h);
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().pts.len(), 2);
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "360 deg");
|
||||
assert!(constraints(&h).is_empty());
|
||||
type_value(&mut h, "90 deg");
|
||||
enter(&mut h);
|
||||
assert_eq!(constraints(&h).iter().filter(|c| c.is_dimension()).count(), 2);
|
||||
|
||||
let mut slot = start("sketch.slot.overall");
|
||||
type_value(&mut slot, "4");
|
||||
enter(&mut slot);
|
||||
type_value(&mut slot, "6");
|
||||
enter(&mut slot);
|
||||
assert_eq!(slot.app.tool.as_ref().unwrap().pts.len(), 2);
|
||||
assert_eq!(slot.app.tool.as_ref().unwrap().dim_history[0].value, "4");
|
||||
assert_eq!(slot.app.tool.as_ref().unwrap().dims[0].value, "6");
|
||||
assert!(constraints(&slot).is_empty());
|
||||
type_value(&mut slot, "2");
|
||||
enter(&mut slot);
|
||||
assert_eq!(constraints(&slot).iter().filter(|c| c.is_dimension()).count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_radius_is_persistent_and_fractional_sides_are_rejected() {
|
||||
let mut h = start("sketch.polygon.inscribed");
|
||||
type_value(&mut h, "10");
|
||||
h.step(&json!({"key":"Tab"})).unwrap();
|
||||
type_value(&mut h, "5.5");
|
||||
enter(&mut h);
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().pts.len(), 1);
|
||||
assert!(constraints(&h).is_empty());
|
||||
type_value(&mut h, "5");
|
||||
enter(&mut h);
|
||||
assert!(constraints(&h).iter().any(|c| matches!(c,ConstraintKind::Radius{value,..} if (*value-10.0).abs()<1e-7)));
|
||||
let state = h.app.session.world_state();
|
||||
let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
|
||||
assert_eq!(sketch.curves.iter().filter(|c| !c.construction).count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_angle_survives_drag_and_one_undo_removes_geometry_and_dimensions() {
|
||||
let mut h = start("sketch.line");
|
||||
let undo = h.app.session.undo.len();
|
||||
type_value(&mut h, "10");
|
||||
h.step(&json!({"key":"Tab"})).unwrap();
|
||||
type_value(&mut h, "30 deg");
|
||||
enter(&mut h);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
assert!(h.app.tool.as_ref().unwrap().dims.iter().all(|d| !d.locked), "next chained segment starts without stale locks");
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
h.step(&json!({"drag":[{"world":[8.660254037844386,5,0]},{"world":[16,14,0]}]})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
|
||||
let CurveKind::Line { a, b } = sketch.curves[0].kind else { panic!("expected line") };
|
||||
let vector = sketch.points[b].pos - sketch.points[a].pos;
|
||||
assert!((vector.len() - 10.0).abs() < 1e-6);
|
||||
assert!((vector.angle() - 30f64.to_radians()).abs() < 1e-6);
|
||||
// A drag may itself add an undo entry. Undo that before the original atomic draw.
|
||||
while h.app.session.undo.len() > undo + 1 {
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(constraints(&h).is_empty());
|
||||
assert!(h.app.session.world_state().sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch.curves.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_drawing_preserves_construction_and_centerline_modes_at_undo_limit() {
|
||||
for mode in [1, 2] {
|
||||
let mut h = start("sketch.line");
|
||||
crate::sketch_palette::set_mode(&mut h.app, mode);
|
||||
let snapshot = h.app.session.undo.last().unwrap().clone();
|
||||
h.app.session.undo.resize(200, snapshot);
|
||||
type_value(&mut h, "10");
|
||||
h.step(&json!({"key":"Tab"})).unwrap();
|
||||
type_value(&mut h, "30 deg");
|
||||
enter(&mut h);
|
||||
assert_eq!(h.app.session.undo.len(), 200);
|
||||
let state = h.app.session.world_state();
|
||||
let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
|
||||
assert_eq!(sketch.curves[0].construction, mode == 1);
|
||||
assert_eq!(sketch.curves[0].centerline, mode == 2);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
|
||||
assert!(sketch.curves.is_empty());
|
||||
assert!(sketch.constraints.is_empty());
|
||||
crate::sketch_palette::set_mode(&mut h.app, 0);
|
||||
}
|
||||
}
|
||||
+78
-23
@@ -31,6 +31,8 @@ pub struct Tool {
|
||||
/// Inline dimension boxes for the shape being drawn, the point count they were made for,
|
||||
/// and whether the first box should take the keyboard.
|
||||
pub dims: Vec<crate::sketch_dims::DimBox>,
|
||||
/// Locked inputs from earlier stages of this shape (arc radius, slot length).
|
||||
pub dim_history: Vec<crate::sketch_dims::DimBox>,
|
||||
pub dims_stage: usize,
|
||||
pub dims_focus: bool,
|
||||
}
|
||||
@@ -67,7 +69,17 @@ impl Tool {
|
||||
"sketch.constraint.symmetry" => ("sketch.constraint.symmetry", Kind::Pick(3)),
|
||||
_ => return crate::sketch_tools::tool_for(id),
|
||||
};
|
||||
Some(Tool { cmd, kind, pts: Vec::new(), hover: None, picks: Vec::new(), dims: Vec::new(), dims_stage: usize::MAX, dims_focus: false })
|
||||
Some(Tool {
|
||||
cmd,
|
||||
kind,
|
||||
pts: Vec::new(),
|
||||
hover: None,
|
||||
picks: Vec::new(),
|
||||
dims: Vec::new(),
|
||||
dim_history: Vec::new(),
|
||||
dims_stage: usize::MAX,
|
||||
dims_focus: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +91,7 @@ pub fn hint(id: &str) -> String {
|
||||
"sketch.rectangle.three_point" => "3-point rectangle: click two points of one edge, then the width".into(),
|
||||
"sketch.circle.center" => "Circle: click the centre, then a point on the circle".into(),
|
||||
"sketch.arc.three_point" => "Arc: click start, end, then a point on the arc".into(),
|
||||
"sketch.arc.center_point" => "Arc: click centre, start, then end (counter-clockwise)".into(),
|
||||
"sketch.arc.center_point" => "Arc: click centre and start, then click end or type a signed sweep".into(),
|
||||
"sketch.dimension" => "Dimension: pick one or two entities, then click to place the value".into(),
|
||||
"sketch.slot.center_to_center" | "sketch.slot.overall" => "Slot: click both ends, then the width".into(),
|
||||
_ if id.starts_with("sketch.constraint.") => "Constraint: pick the sketch entities".into(),
|
||||
@@ -100,7 +112,13 @@ fn xy(p: &(Vec2, Option<String>)) -> Value {
|
||||
|
||||
pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
|
||||
crate::sketch3d::on_hover(app, proj, pos);
|
||||
let h = sketch_point_at(app, proj, pos);
|
||||
let h = if let Some(point) = app.tool.as_ref().and_then(|tool| crate::sketch_tools::periodic_start_snap(app, tool, proj, pos)) {
|
||||
Some((point, Some("Close smooth loop".into())))
|
||||
} else if let Some(tool) = app.tool.as_ref().filter(|t| t.cmd == "sketch.fillet" || t.cmd.starts_with("sketch.chamfer.")) {
|
||||
crate::sketch_tools::corner_point(app, proj, pos, tool.cmd).map(|(at, id)| (at, Some(id)))
|
||||
} else {
|
||||
sketch_point_at(app, proj, pos)
|
||||
};
|
||||
if let Some(t) = app.tool.as_mut() {
|
||||
t.hover = h;
|
||||
}
|
||||
@@ -171,40 +189,77 @@ pub fn place(app: &mut SolveApp, p: (Vec2, Option<String>)) {
|
||||
app.tool = Some(tool);
|
||||
return;
|
||||
};
|
||||
if let Err(error) = crate::sketch_dims::validate(app, &tool) {
|
||||
app.set_status(error, true);
|
||||
app.tool = Some(tool);
|
||||
return;
|
||||
}
|
||||
tool.pts.push(p);
|
||||
if tool.cmd == "sketch.line" {
|
||||
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("sketch.line", json!({"points": [arg(&a), arg(&b)], "infer": true})) {
|
||||
let angled = crate::sketch_dims::has_locked(&tool, "Angle");
|
||||
let dimensioned = tool.dims.iter().any(|dimension| dimension.locked);
|
||||
if let Ok(v) = draw(app, &tool, json!({"points": [arg(&a), arg(&b)], "infer": !angled})) {
|
||||
let curves: Vec<String> = v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect();
|
||||
dimension(app, &tool, &curves);
|
||||
// Chain: the next segment starts at this one's end point.
|
||||
if let Some(id) = curves.first() {
|
||||
crate::inference::commit(app, id, b.0);
|
||||
if angled {
|
||||
crate::inference::clear();
|
||||
} else {
|
||||
// Endpoint inference is part of this drawing. Preserve its original
|
||||
// undo history even when the history is already at the engine's cap.
|
||||
let history = dimensioned.then(|| std::mem::take(&mut app.session.undo));
|
||||
crate::inference::commit(app, id, b.0);
|
||||
if let Some(history) = history {
|
||||
app.session.undo = history;
|
||||
}
|
||||
}
|
||||
let last = tool.pts.len() - 1;
|
||||
tool.pts[last].1 = Some(format!("{id}.end"));
|
||||
// The chain needs only its current endpoint, even after many segments.
|
||||
tool.pts = vec![tool.pts[last].clone()];
|
||||
}
|
||||
crate::sketch_dims::reset(&mut tool);
|
||||
} else {
|
||||
tool.pts.pop();
|
||||
}
|
||||
}
|
||||
} else if n > 0 && tool.pts.len() >= n {
|
||||
let curves = run_shape(app, &tool);
|
||||
dimension(app, &tool, &curves);
|
||||
tool.pts.clear();
|
||||
match run_shape(app, &tool) {
|
||||
Ok(_) => {
|
||||
tool.pts.clear();
|
||||
crate::sketch_dims::reset(&mut tool);
|
||||
}
|
||||
Err(error) => {
|
||||
app.set_status(error, true);
|
||||
tool.pts.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
app.tool = Some(tool);
|
||||
}
|
||||
|
||||
/// Typed values become dimension constraints on the new curves.
|
||||
fn dimension(app: &mut SolveApp, t: &Tool, curves: &[String]) {
|
||||
for (ents, ty, value) in crate::sketch_dims::constraints(t, curves) {
|
||||
let _ = app.run("sketch.dimension", crate::sketch_dims::dimension_params(&ents, ty, &value));
|
||||
/// Geometry and typed constraints are committed atomically, as one undo step.
|
||||
fn draw(app: &mut SolveApp, t: &Tool, params: Value) -> Result<Value, String> {
|
||||
let dimensions = crate::sketch_dims::dimensions(t);
|
||||
if dimensions.as_object().is_none_or(|values| values.is_empty()) {
|
||||
app.run(t.cmd, params)
|
||||
} else {
|
||||
let result = app.run("sketch.draw.dimensioned", json!({"command": t.cmd, "params": params, "dimensions": dimensions}))?;
|
||||
// The internal wrapper has no toolbar category; apply the original creator's line
|
||||
// type while retaining the draw's undo history, including at the history limit.
|
||||
let history = std::mem::take(&mut app.session.undo);
|
||||
crate::sketch_palette::apply_mode(app, t.cmd, &result);
|
||||
app.session.undo = history;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_shape(app: &mut SolveApp, t: &Tool) -> Vec<String> {
|
||||
fn run_shape(app: &mut SolveApp, t: &Tool) -> Result<Vec<String>, String> {
|
||||
let p = &t.pts;
|
||||
let d = |a: usize, b: usize| p[a].0.dist(p[b].0);
|
||||
let sides = crate::sketch_dims::locked_value(app, t, "Sides").map_or(6, |v| v.round().clamp(3.0, 200.0) as usize);
|
||||
let sides = crate::sketch_dims::locked_value(app, t, "Sides").map_or(6, |v| v as usize);
|
||||
let params = match t.cmd {
|
||||
"sketch.rectangle.two_point" => json!({"p0": xy(&p[0]), "p1": xy(&p[1])}),
|
||||
"sketch.rectangle.center" => json!({"center": xy(&p[0]), "corner": xy(&p[1])}),
|
||||
@@ -213,7 +268,10 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Vec<String> {
|
||||
"sketch.circle.two_point" => json!({"p0": xy(&p[0]), "p1": xy(&p[1])}),
|
||||
"sketch.circle.three_point" => json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "p2": xy(&p[2])}),
|
||||
"sketch.arc.three_point" => json!({"start": arg(&p[0]), "end": arg(&p[1]), "through": xy(&p[2])}),
|
||||
"sketch.arc.center_point" => json!({"center": arg(&p[0]), "start": arg(&p[1]), "end": xy(&p[2])}),
|
||||
"sketch.arc.center_point" => match crate::sketch_dims::locked_value(app, t, "Sweep") {
|
||||
Some(sweep) => json!({"center": arg(&p[0]), "start": arg(&p[1]), "sweep": sweep.to_degrees()}),
|
||||
None => json!({"center": arg(&p[0]), "start": arg(&p[1]), "end": xy(&p[2])}),
|
||||
},
|
||||
"sketch.polygon.inscribed" | "sketch.polygon.circumscribed" => {
|
||||
let a = (p[1].0 - p[0].0).angle().to_degrees();
|
||||
json!({"center": xy(&p[0]), "radius": d(0, 1), "sides": sides, "angle": a})
|
||||
@@ -225,14 +283,10 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Vec<String> {
|
||||
json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "width": w.max(1e-3)})
|
||||
}
|
||||
"sketch.point" => json!({"point": xy(&p[0])}),
|
||||
_ => return Vec::new(),
|
||||
_ => return Err(format!("drawing {} is not supported", t.cmd)),
|
||||
};
|
||||
let curves: Vec<String> = app
|
||||
.run(t.cmd, params)
|
||||
.ok()
|
||||
.map(|v| v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect())
|
||||
.unwrap_or_default();
|
||||
curves
|
||||
let result = draw(app, t, params)?;
|
||||
Ok(result["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect())
|
||||
}
|
||||
|
||||
fn run_pick(app: &mut SolveApp, t: &Tool) {
|
||||
@@ -264,6 +318,7 @@ pub fn finish(app: &mut SolveApp) {
|
||||
{
|
||||
t.pts.clear();
|
||||
t.picks.clear();
|
||||
crate::sketch_dims::reset(t);
|
||||
return;
|
||||
}
|
||||
app.tool = None;
|
||||
|
||||
@@ -15,6 +15,10 @@ use crate::selection::{BoxSel, origin_quads, origin_size, ray_quad};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{SolveApp, icons};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "viewport_appearance_tests.rs"]
|
||||
mod appearance_tests;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NavMode {
|
||||
Orbit,
|
||||
@@ -214,11 +218,14 @@ fn scene_key(app: &SolveApp) -> u64 {
|
||||
(app.cam.pitch > 0.0).hash(&mut h);
|
||||
app.ui.show_sketches.hash(&mut h);
|
||||
app.session.visibility.hidden_bodies.hash(&mut h);
|
||||
app.session.visibility.hidden_occurrences.hash(&mut h);
|
||||
app.session.visibility.hidden_sketches.hash(&mut h);
|
||||
app.session.visibility.shown_sketches.hash(&mut h);
|
||||
app.ui.hidden_profiles.hash(&mut h);
|
||||
app.session.active_component.hash(&mut h);
|
||||
app.session.active_occurrence_path.hash(&mut h);
|
||||
app.preview.replaced.hash(&mut h);
|
||||
app.tree.redefine.hash(&mut h);
|
||||
crate::theme::is_dark().hash(&mut h);
|
||||
app.session.active_sketch.hash(&mut h);
|
||||
let (minor, _) = grid_step(app.cam.half_height());
|
||||
@@ -264,12 +271,17 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
sc.under_line(w3(c + Vec3::new(-ext, t, 0.0)), w3(c + Vec3::new(ext, t, 0.0)), col, w);
|
||||
}
|
||||
}
|
||||
let active_bodies = s.active_occurrence_path.as_ref().and_then(|p| s.occurrence_bodies(p, true).ok());
|
||||
for b in &st.bodies {
|
||||
if app.session.visibility.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) {
|
||||
if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) {
|
||||
continue;
|
||||
}
|
||||
// With a component active, bodies outside it are drawn faded.
|
||||
let faded = s.active_component != 0 && !s.doc.component_within(s.doc.body_component(&b.name, b.feature), s.active_component);
|
||||
let faded = if let Some(names) = &active_bodies {
|
||||
!names.contains(&b.name)
|
||||
} else {
|
||||
s.active_component != 0 && !s.doc.component_within(b.component, s.active_component)
|
||||
};
|
||||
let base = body_color(&s.doc, b).unwrap_or(tk.body);
|
||||
let col = if faded { c4(base.gamma_multiply(0.35)) } else { c4(base) };
|
||||
let m = b.mesh();
|
||||
@@ -283,7 +295,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
let hidden_edges = app.ui.visual_style == 3;
|
||||
let hidden_col = c4(tk.text.gamma_multiply(0.35));
|
||||
// Appearances: the body's opacity, and faces with looks of their own.
|
||||
let opacity = s.doc.body_look(&b.name, b.feature).map_or(1.0, |l| l.opacity);
|
||||
let opacity = s.doc.model_body_opacity(b);
|
||||
let face_looks: std::collections::HashMap<usize, solvecraft_engine::doc::appearance::Look> =
|
||||
if shaded { s.doc.face_colors(b).into_iter().collect() } else { Default::default() };
|
||||
for (ti, t) in m.triangles.iter().enumerate().filter(|_| shaded) {
|
||||
@@ -329,7 +341,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
// Section: the outline of each body where the plane cuts it.
|
||||
if let Some((o, n)) = section_plane(app) {
|
||||
let col = c4(tk.text.gamma_multiply(0.9));
|
||||
for b in st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name)) {
|
||||
for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name)) {
|
||||
for (a, c) in solvecraft_engine::view::section_segments(&b.mesh(), o, n) {
|
||||
sc.line(a.to_f32(), c.to_f32(), col, 1.8, false);
|
||||
}
|
||||
@@ -363,6 +375,12 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
}
|
||||
}
|
||||
}
|
||||
let guide_unit = grid_step(app.cam.half_height()).0;
|
||||
for points in crate::sketch_attachment_ui::guide_lines(app) {
|
||||
for (a, b) in dashes(&points, &[guide_unit * 0.25, guide_unit * 0.15]) {
|
||||
sc.line(a.to_f32(), b.to_f32(), c4(tk.warning), 1.5, true);
|
||||
}
|
||||
}
|
||||
sc
|
||||
}
|
||||
|
||||
@@ -370,7 +388,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc::ModelState) {
|
||||
// The bodies' box in the camera's Z-up frame (the ground is its XY plane).
|
||||
let mut b = solvecraft_engine::geom::Aabb3::EMPTY;
|
||||
for x in st.bodies.iter().filter(|x| !app.session.visibility.hidden_bodies.contains(&x.name)) {
|
||||
for x in st.bodies.iter().filter(|x| app.session.body_visible(&x.name)) {
|
||||
let bb = x.mesh().bounds();
|
||||
if bb.is_empty() {
|
||||
continue;
|
||||
@@ -561,6 +579,9 @@ pub fn model_bounds(app: &SolveApp) -> solvecraft_engine::geom::Aabb3 {
|
||||
}
|
||||
}
|
||||
}
|
||||
for point in crate::sketch_attachment_ui::guide_lines(app).into_iter().flatten() {
|
||||
b.add(point);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
@@ -610,6 +631,7 @@ fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||||
app.session.active_sketch.hash(&mut h);
|
||||
app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).hash(&mut h);
|
||||
app.session.visibility.hidden_bodies.hash(&mut h);
|
||||
app.session.visibility.hidden_occurrences.hash(&mut h);
|
||||
app.ui.perspective.hash(&mut h);
|
||||
(proj.rect.width().to_bits(), proj.rect.height().to_bits()).hash(&mut h);
|
||||
let key = h.finish();
|
||||
@@ -685,7 +707,7 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||||
// Nearest face along the ray (hides what is behind it).
|
||||
let mut bestf: Option<(f64, Hit)> = None;
|
||||
for b in &st.bodies {
|
||||
if app.session.visibility.hidden_bodies.contains(&b.name) {
|
||||
if !app.session.body_visible(&b.name) {
|
||||
continue;
|
||||
}
|
||||
// Bodies whose box the ray misses (or enters behind the best hit so far) are skipped.
|
||||
@@ -711,7 +733,7 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||||
let mut bestv: Option<(f32, Hit)> = None;
|
||||
let mut beste: Option<(f32, Hit)> = None;
|
||||
for b in &st.bodies {
|
||||
if app.session.visibility.hidden_bodies.contains(&b.name) {
|
||||
if !app.session.body_visible(&b.name) {
|
||||
continue;
|
||||
}
|
||||
// Only bodies whose outline on screen is near the cursor can have an edge under it.
|
||||
@@ -852,7 +874,7 @@ fn feature_geometry(app: &SolveApp, id: u64) -> Vec<Sel> {
|
||||
let prefix = format!("F{id}:");
|
||||
let mut out = Vec::new();
|
||||
for b in &st.bodies {
|
||||
if app.session.visibility.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) {
|
||||
if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) {
|
||||
continue;
|
||||
}
|
||||
if b.feature == id {
|
||||
@@ -878,6 +900,14 @@ fn feature_geometry(app: &SolveApp, id: u64) -> Vec<Sel> {
|
||||
fn displayed_selection(app: &SolveApp, item: &Sel) -> Vec<Sel> {
|
||||
match item {
|
||||
Sel::Feature { id } => feature_geometry(app, *id),
|
||||
Sel::Occurrence { path } => app
|
||||
.session
|
||||
.occurrence_bodies(path, true)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|name| app.session.body_visible(name))
|
||||
.map(|name| Sel::Body { name })
|
||||
.collect(),
|
||||
_ => grouped_selection(&app.session, item),
|
||||
}
|
||||
}
|
||||
@@ -1048,13 +1078,18 @@ fn hidden_face_outline(sc: &mut GpuScene, m: &solvecraft_engine::geom::Mesh, fac
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) {
|
||||
pub(crate) fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) {
|
||||
let st = app.session.world_state();
|
||||
let Some(ss) = st.sketch(sketch) else { return };
|
||||
let Some(p) = ss.profiles.get(index) else { return };
|
||||
let plane = match crate::dialogs::emboss_profile_plane(app, sketch) {
|
||||
Ok(Some(plane)) => plane,
|
||||
Ok(None) => ss.plane,
|
||||
Err(_) => return,
|
||||
};
|
||||
// Lift toward the eye so a profile on a face shows over it.
|
||||
let lift = app.cam.back() * (app.cam.half_height() * 0.002);
|
||||
let w = |q: solvecraft_engine::geom::Vec2| (ss.plane.to_world(q) + lift).to_f32();
|
||||
let w = |q: solvecraft_engine::geom::Vec2| (plane.to_world(q) + lift).to_f32();
|
||||
let col = c4(fill);
|
||||
// In the translucent pass (after the grid and edges) so it covers them.
|
||||
for [a, b, c] in p.region.triangulate(0.02) {
|
||||
@@ -1098,6 +1133,7 @@ fn highlight_key(app: &SolveApp) -> u64 {
|
||||
app.origin_visible().hash(&mut h);
|
||||
app.session.visibility.hidden_origin.hash(&mut h);
|
||||
app.session.visibility.hidden_bodies.hash(&mut h);
|
||||
app.session.visibility.hidden_occurrences.hash(&mut h);
|
||||
app.cam.half_height().to_bits().hash(&mut h);
|
||||
app.cam.back().x.to_bits().hash(&mut h);
|
||||
h.finish()
|
||||
@@ -1180,14 +1216,14 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
match x {
|
||||
Sel::Face { body, index, .. } => {
|
||||
// A live preview stands in for the body: its old faces would fight with it.
|
||||
if let Some(b) = st.body(body).filter(|_| !app.preview.replaced.contains(body)) {
|
||||
if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !app.preview.replaced.contains(body)) {
|
||||
face_tris(&mut sc, &b.mesh(), *index, c4(t.sel_face), false);
|
||||
hidden_face_outline(&mut sc, &b.mesh(), *index, t.sel_edge);
|
||||
}
|
||||
}
|
||||
Sel::Body { name } => {
|
||||
if let Some(b) = st.body(name)
|
||||
&& !app.session.visibility.hidden_bodies.contains(name)
|
||||
&& app.session.body_visible(name)
|
||||
{
|
||||
let m = b.mesh();
|
||||
for f in 0..b.body.face_count() {
|
||||
@@ -1197,6 +1233,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
}
|
||||
Sel::Edge { body, index, point } => {
|
||||
if let Some(b) = st.body(body)
|
||||
&& app.session.body_visible(body)
|
||||
&& let Some(e) = edge_of(&b.mesh(), *index, *point)
|
||||
{
|
||||
edge_lines(&mut sc, e, t.sel_edge, t.sel_edge_rim, 3.0);
|
||||
@@ -1231,7 +1268,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
// Timeline modifiers light only their resulting faces; browser rows light whole bodies.
|
||||
let mut hovered = app.viewport.hover_feature.map(|id| feature_geometry(app, id)).unwrap_or_default();
|
||||
hovered.extend(app.viewport.hover_bodies.iter().map(|name| Sel::Body { name: name.clone() }));
|
||||
for b in st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name) && !app.preview.replaced.contains(&b.name)) {
|
||||
for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name) && !app.preview.replaced.contains(&b.name)) {
|
||||
let whole = hovered.iter().any(|x| matches!(x, Sel::Body { name } if name == &b.name));
|
||||
let faces: Vec<_> = if whole {
|
||||
(0..b.body.face_count()).collect()
|
||||
@@ -1746,6 +1783,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::drag_snap::show(app, &painter, &proj);
|
||||
hover_highlight(app, &painter, &proj);
|
||||
points_2d(app, &painter, &proj);
|
||||
if let Some((plane, sketch)) = crate::sketch_attachment_ui::lost_guide(app)
|
||||
&& let Some((low, high)) = sketch.bounds()
|
||||
&& let Some(at) = proj.to_screen(plane.to_world(solvecraft_engine::geom::Vec2::new((low.x + high.x) * 0.5, high.y)))
|
||||
{
|
||||
painter.text(at - vec2(0.0, 12.0), Align2::CENTER_BOTTOM, "Saved sketch plane — attachment lost", FontId::proportional(12.0), t.warning);
|
||||
}
|
||||
if let Some(bx) = app.viewport.boxsel {
|
||||
let col = if bx.crossing { t.box_crossing } else { t.box_window };
|
||||
painter.rect_filled(bx.rect, 0.0, col.gamma_multiply(0.12));
|
||||
@@ -1914,7 +1957,7 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
|
||||
} else {
|
||||
let accept = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).unwrap_or(BODIES);
|
||||
for b in &st.bodies {
|
||||
if app.session.visibility.hidden_bodies.contains(&b.name) {
|
||||
if !app.session.body_visible(&b.name) {
|
||||
continue;
|
||||
}
|
||||
let m = b.mesh();
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
use super::build_scene;
|
||||
use crate::{Services, SolveApp};
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::{Session, doc, geom::Vec3, kernel};
|
||||
|
||||
#[test]
|
||||
fn placed_step_body_uses_imported_opacity_until_a_design_look_overrides_it() {
|
||||
let body = kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0))
|
||||
.unwrap()
|
||||
.with_color(Some([0.2, 0.6, 0.8]))
|
||||
.with_paint(Some(kernel::Paint { opacity: 0.4, faces: Vec::new() }));
|
||||
let step =
|
||||
kernel::step_export_bodies(&[kernel::ExportBody { name: "Imported glass".into(), body: &body, color: body.color() }], &Default::default())
|
||||
.unwrap();
|
||||
let mut document = doc::Document::default();
|
||||
let component = document.add_component(Some("Imported part"), 0).unwrap();
|
||||
let feature = document.add_feature(doc::FeatureKind::Import { file: "glass.step".into(), step, components: Vec::new() }, None).unwrap();
|
||||
document.features.iter_mut().find(|f| f.id == feature).unwrap().component = component;
|
||||
let mut session = Session::new(document);
|
||||
let source = session.model.state().bodies[0].name.clone();
|
||||
assert!((session.doc.model_body_opacity(&session.model.state().bodies[0]) - 0.4).abs() < 1e-6);
|
||||
session.execute("occurrence.copy", &json!({"component":component,"translate":[30,0,0]})).unwrap();
|
||||
let mut app = SolveApp::new(session, Services::default());
|
||||
app.ui.ground_shadow = false;
|
||||
app.ui.show_origin = false;
|
||||
let placed = app.session.world_state().bodies.iter().find(|b| b.name != source).unwrap().name.clone();
|
||||
app.session.selection = vec![solvecraft_engine::Sel::Body { name: placed }];
|
||||
app.start("appearance.assign");
|
||||
let crate::dialogs::Kind::Appearance(appearance) = &app.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") };
|
||||
assert!((appearance.opacity - 0.4).abs() < 1e-6);
|
||||
assert_eq!(appearance.color, [51, 153, 204]);
|
||||
crate::dialogs::cancel(&mut app);
|
||||
let scene = build_scene(&app);
|
||||
assert!(!scene.glass.is_empty());
|
||||
assert!(
|
||||
scene.tris.is_empty(),
|
||||
"opaque triangles {}, bodies {:?}",
|
||||
scene.tris.len(),
|
||||
app.session.world_state().bodies.iter().map(|b| (&b.name, b.body.paint(), &b.imported)).collect::<Vec<_>>()
|
||||
);
|
||||
let (vertices, remainder) = scene.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>();
|
||||
assert!(remainder.is_empty());
|
||||
assert!(vertices.iter().all(|v| v[27] == 102));
|
||||
app.run("appearance.assign", json!({"bodies":[source],"color":"#336699","opacity":0.7})).unwrap();
|
||||
let scene = build_scene(&app);
|
||||
let (vertices, remainder) = scene.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>();
|
||||
assert!(remainder.is_empty());
|
||||
assert!(vertices.iter().all(|v| v[27] == 179));
|
||||
app.run("appearance.assign", json!({"bodies":[source]})).unwrap();
|
||||
let scene = build_scene(&app);
|
||||
let (vertices, remainder) = scene.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>();
|
||||
assert!(remainder.is_empty());
|
||||
assert!(vertices.iter().all(|v| v[27] == 102));
|
||||
}
|
||||
Reference in new issue
Block a user