feat: preserve modeling intent across sketches, assemblies, and fabrication
Persist typed sketch dimensions and bounded ordered configuration rules in format 8. Use exact occurrence paths and source identity for editing, movement, appearance, and mass. Preserve sheet material regions through cutting, folding, and exact DXF export; retain nested cavities and material islands. Validation: cargo xtask ci passed, including 368 engine tests, 178 UI tests, 68 UI scenarios, 136 kernel tests, and the 10-crate wasm check. Actual preview workflows and legacy format-7 origin-corner reopening were inspected.
This commit is contained in:
1 parent
d71bdb78ec
commit
07932d0f42
65 files changed
+6145
-484
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);
|
||||
|
||||
+209
-21
@@ -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 {
|
||||
@@ -59,6 +68,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 +84,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +202,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 +454,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 +496,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)
|
||||
@@ -598,9 +620,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1004,7 +1126,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
|
||||
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),
|
||||
@@ -1082,9 +1204,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 +1243,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 +1321,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 +1350,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 +1366,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 +1403,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 +1518,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 +1554,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 +1599,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 +1622,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 })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
//! 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(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 { .. }
|
||||
@@ -292,6 +296,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),
|
||||
@@ -504,6 +509,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);
|
||||
@@ -681,7 +734,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" => {
|
||||
@@ -722,8 +775,14 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
start_rename(app, what, at);
|
||||
}
|
||||
"ui.properties" => {
|
||||
let command = if p.get("component").is_some() { "component.properties" } else { "inspect.measure" };
|
||||
let params = if command == "component.properties" { p.clone() } else { json!({ "bodies": bodies }) };
|
||||
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"]
|
||||
@@ -798,15 +857,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" => {}
|
||||
@@ -948,6 +1019,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));
|
||||
@@ -994,6 +1071,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,
|
||||
@@ -1424,14 +1505,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() });
|
||||
|
||||
@@ -303,6 +303,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) {
|
||||
@@ -465,6 +475,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) {
|
||||
|
||||
@@ -282,7 +282,7 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
&& !app.session.selection.iter().any(|s| matches!(s, Sel::Body { .. }));
|
||||
let state = app.session.world_state();
|
||||
let existing = app.session.selection.iter().find_map(|sel| match sel {
|
||||
Sel::Body { name } => state.body(name).and_then(|b| app.session.doc.body_look(name, b.feature)),
|
||||
Sel::Body { name } => state.body(name).and_then(|b| app.session.doc.model_body_look(b)),
|
||||
Sel::Face { body, index, .. } => state.body(body).and_then(|b| {
|
||||
app.session
|
||||
.doc
|
||||
@@ -127,7 +127,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
.into_iter()
|
||||
.find(|(i, _)| i == index)
|
||||
.map(|(_, look)| look)
|
||||
.or_else(|| app.session.doc.body_look(body, b.feature))
|
||||
.or_else(|| app.session.doc.model_body_look(b))
|
||||
}),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -680,14 +699,14 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
run(app, error, "config.row", json!({"name": name, "values": { c: choice }}));
|
||||
}
|
||||
}
|
||||
let id = egui::Id::new(("cfg", name, c));
|
||||
let mut text = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone());
|
||||
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() {
|
||||
ui.data_mut(|d| d.insert_temp(id, text.clone()));
|
||||
rule_editor.drafts.insert(id.clone(), text.clone());
|
||||
}
|
||||
if r.lost_focus() {
|
||||
ui.data_mut(|d| d.remove::<String>(id));
|
||||
rule_editor.drafts.remove(&id);
|
||||
if text != shown {
|
||||
run(app, error, "config.row", json!({"name": name, "values": { c: cell_value(c, &text) }}));
|
||||
}
|
||||
@@ -771,6 +790,7 @@ 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(e) = error {
|
||||
ui.label("");
|
||||
ui.label(RichText::new(e.as_str()).color(t.error));
|
||||
@@ -908,6 +928,82 @@ 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_style_picker_applies_then_inherits_material() {
|
||||
let steps: Value = serde_json::from_str(include_str!("../tests/scenarios/s80_configuration_styles.json")).unwrap();
|
||||
|
||||
@@ -81,6 +81,7 @@ 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;
|
||||
let out_rev = app.session.revision;
|
||||
let out = DocSlot {
|
||||
session: std::mem::replace(&mut app.session, slot.session),
|
||||
@@ -140,6 +141,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
|
||||
let rev = app.session.revision;
|
||||
crate::dialogs::cancel(app);
|
||||
app.tool = None;
|
||||
app.menu.props = None;
|
||||
app.session = session;
|
||||
app.session.invalidate_view(rev);
|
||||
app.cam = Camera::default();
|
||||
|
||||
@@ -404,7 +404,13 @@ 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;
|
||||
// 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();
|
||||
}
|
||||
@@ -615,7 +621,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()
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
use crate::context_menu::{self, Target};
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::Sel;
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -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).
|
||||
@@ -285,7 +327,9 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
|
||||
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).event_filter(egui::EventFilter {
|
||||
// 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,
|
||||
@@ -326,11 +370,10 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
|
||||
(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;
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+71
-22
@@ -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(),
|
||||
@@ -175,40 +187,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])}),
|
||||
@@ -217,7 +266,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})
|
||||
@@ -229,14 +281,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) {
|
||||
@@ -268,6 +316,7 @@ pub fn finish(app: &mut SolveApp) {
|
||||
{
|
||||
t.pts.clear();
|
||||
t.picks.clear();
|
||||
crate::sketch_dims::reset(t);
|
||||
return;
|
||||
}
|
||||
app.tool = None;
|
||||
|
||||
@@ -214,10 +214,12 @@ 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);
|
||||
crate::theme::is_dark().hash(&mut h);
|
||||
app.session.active_sketch.hash(&mut h);
|
||||
@@ -264,12 +266,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 +290,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_look(b).map_or(1.0, |l| l.opacity);
|
||||
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 +336,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 section_segments(&b.mesh(), o, n) {
|
||||
sc.line(a.to_f32(), c.to_f32(), col, 1.8, false);
|
||||
}
|
||||
@@ -370,7 +377,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;
|
||||
@@ -632,6 +639,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();
|
||||
@@ -707,7 +715,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.
|
||||
@@ -733,7 +741,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.
|
||||
@@ -874,7 +882,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 {
|
||||
@@ -900,6 +908,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),
|
||||
}
|
||||
}
|
||||
@@ -1120,6 +1136,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()
|
||||
@@ -1202,14 +1219,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() {
|
||||
@@ -1219,6 +1236,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);
|
||||
@@ -1253,7 +1271,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()
|
||||
@@ -1936,7 +1954,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();
|
||||
|
||||
Reference in new issue
Block a user