forked from 1b-agent/opencadstudio-upstream
feat(spline): improve fit and CV editing
Keep planar conversions kernel-backed and leave unsupported 3D, CV-to-fit, and closed rebuilds unavailable instead of changing curve data.\n\nRefs #815
This commit is contained in:
commit
1c408e3a73
16 files changed
+929
-355
No files matched your search
@@ -82,6 +82,8 @@ pub struct GripPopup {
|
||||
pub anchor: iced::Point,
|
||||
pub items: Vec<crate::scene::model::object::GripMenuItem>,
|
||||
pub selected: usize,
|
||||
/// Whether a click-opened menu stays visible away from its grip.
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
/// Pending follow-up value for grip-menu actions that need a number
|
||||
|
||||
@@ -121,6 +121,13 @@ impl OpenCADStudio {
|
||||
acadrust::EntityType::LwPolyline(polyline) => Some(polyline.vertices.len()),
|
||||
acadrust::EntityType::Polyline2D(polyline) => Some(polyline.vertices.len()),
|
||||
acadrust::EntityType::Leader(leader) => Some(leader.vertices.len()),
|
||||
acadrust::EntityType::Spline(spline) => Some(
|
||||
if crate::entities::spline::uses_fit_method(spline) {
|
||||
spline.fit_points.len()
|
||||
} else {
|
||||
spline.control_points.len()
|
||||
},
|
||||
),
|
||||
_ => None,
|
||||
});
|
||||
vertex_count.map_or(prop_vertex, |count| prop_vertex.min(count.saturating_sub(1)))
|
||||
|
||||
@@ -1322,6 +1322,27 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
}
|
||||
}
|
||||
// One-shot action — apply immediately.
|
||||
let unchanged = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(popup.handle)
|
||||
.is_some_and(|entity| match (item.action, entity) {
|
||||
(GripMenuAction::ShowFit, acadrust::EntityType::Spline(spline)) => {
|
||||
!spline.cv_frame_visible
|
||||
&& crate::entities::spline::uses_fit_method(spline)
|
||||
}
|
||||
(
|
||||
GripMenuAction::ShowControlVertices,
|
||||
acadrust::EntityType::Spline(spline),
|
||||
) => {
|
||||
spline.cv_frame_visible
|
||||
|| !crate::entities::spline::uses_fit_method(spline)
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
if unchanged {
|
||||
return Task::none();
|
||||
}
|
||||
self.push_undo_snapshot(i, item.label);
|
||||
// For Add Leader, the new arrow becomes the last grip; remember
|
||||
// its id so we can grab it for placement right after.
|
||||
@@ -1728,6 +1749,40 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
let handles = self.property_target_handles(i);
|
||||
|
||||
if !handles.is_empty() {
|
||||
if matches!(field, "spline_method" | "knot_param" | "cv_frame") {
|
||||
let unchanged = handles.iter().all(|handle| {
|
||||
let Some(acadrust::EntityType::Spline(spline)) =
|
||||
self.tabs[i].scene.document.get_entity(*handle)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
match field {
|
||||
"spline_method" => {
|
||||
value == if crate::entities::spline::uses_fit_method(spline) {
|
||||
"Fit"
|
||||
} else {
|
||||
"Control Vertices"
|
||||
}
|
||||
}
|
||||
"knot_param" => {
|
||||
let current = match spline.knot_parameterization {
|
||||
0 => "Chord",
|
||||
1 => "Square Root",
|
||||
2 => "Uniform",
|
||||
_ => "Custom",
|
||||
};
|
||||
value == current
|
||||
}
|
||||
"cv_frame" => {
|
||||
value == if spline.cv_frame_visible { "Show" } else { "Hide" }
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
if unchanged {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
|
||||
if field == "vscale_std" {
|
||||
@@ -2099,6 +2154,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
}
|
||||
self.invalidate_property_targets(i, &handles);
|
||||
self.tabs[i].dirty = true;
|
||||
if field == "spline_method" {
|
||||
self.tabs[i].properties.prop_vertex = 0;
|
||||
self.tabs[i].properties.prop_vertex_indicator_active = false;
|
||||
}
|
||||
self.refresh_properties();
|
||||
}
|
||||
Task::none()
|
||||
@@ -2121,6 +2180,35 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
} else {
|
||||
crate::app::expr_eval::eval_to_string(&raw_val)
|
||||
};
|
||||
if matches!(field, "current_fit_point" | "current_control_point") {
|
||||
let count = handles
|
||||
.iter()
|
||||
.filter_map(|handle| {
|
||||
let entity = self.tabs[i].scene.document.get_entity(*handle)?;
|
||||
match (field, entity) {
|
||||
(
|
||||
"current_fit_point",
|
||||
acadrust::EntityType::Spline(spline),
|
||||
) => Some(spline.fit_points.len()),
|
||||
(
|
||||
"current_control_point",
|
||||
acadrust::EntityType::Spline(spline),
|
||||
) => Some(spline.control_points.len()),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
if let Ok(requested) = val.trim().parse::<usize>() {
|
||||
if count > 0 {
|
||||
let next = requested.clamp(1, count) - 1;
|
||||
self.tabs[i].properties.prop_vertex = next;
|
||||
self.tabs[i].properties.prop_vertex_indicator_active = true;
|
||||
}
|
||||
}
|
||||
self.refresh_properties();
|
||||
return Task::none();
|
||||
}
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
if field == "block" {
|
||||
// Name row on a block reference: an existing name
|
||||
|
||||
@@ -653,7 +653,9 @@ impl OpenCADStudio {
|
||||
screen: p,
|
||||
started: iced::time::Instant::now(),
|
||||
});
|
||||
self.grip_popup = None;
|
||||
if !self.grip_popup.as_ref().is_some_and(|popup| popup.pinned) {
|
||||
self.grip_popup = None;
|
||||
}
|
||||
} else if let Some(h) = self.grip_hover.as_mut() {
|
||||
h.screen = p;
|
||||
}
|
||||
@@ -672,12 +674,17 @@ impl OpenCADStudio {
|
||||
use crate::entities::traits::EntityTypeOps;
|
||||
let items = e.grip_menu(grip_id);
|
||||
if !items.is_empty() {
|
||||
let selected = items
|
||||
.iter()
|
||||
.position(|item| item.label.starts_with('✓'))
|
||||
.unwrap_or(0);
|
||||
self.grip_popup = Some(crate::app::GripPopup {
|
||||
handle,
|
||||
grip_id,
|
||||
anchor: p,
|
||||
items,
|
||||
selected: 0,
|
||||
selected,
|
||||
pinned: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -685,7 +692,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
None => {
|
||||
self.grip_hover = None;
|
||||
if let Some(popup) = &self.grip_popup {
|
||||
if let Some(popup) = self.grip_popup.as_ref().filter(|popup| !popup.pinned) {
|
||||
let dx = p.x - popup.anchor.x;
|
||||
let dy = p.y - popup.anchor.y;
|
||||
if (dx * dx + dy * dy).sqrt() > POPUP_DISMISS_PX {
|
||||
@@ -2736,6 +2743,32 @@ impl OpenCADStudio {
|
||||
let Some(&handle) = self.tabs[i].selected_grip_handles.get(grip_index) else {
|
||||
return Task::none();
|
||||
};
|
||||
let grip_shape = self.tabs[i].selected_grips[grip_index].shape;
|
||||
if grip_shape == crate::scene::model::object::GripShape::Dropdown {
|
||||
use crate::entities::traits::EntityTypeOps;
|
||||
let items = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(handle)
|
||||
.map(|entity| entity.grip_menu(grip_id))
|
||||
.unwrap_or_default();
|
||||
if !items.is_empty() {
|
||||
let selected = items
|
||||
.iter()
|
||||
.position(|item| item.label.starts_with('✓'))
|
||||
.unwrap_or(0);
|
||||
self.grip_popup = Some(crate::app::GripPopup {
|
||||
handle,
|
||||
grip_id,
|
||||
anchor: p_full,
|
||||
items,
|
||||
selected,
|
||||
pinned: true,
|
||||
});
|
||||
}
|
||||
self.grip_hover = None;
|
||||
return Task::none();
|
||||
}
|
||||
// The visibility (lookup) grip opens a state
|
||||
// dropdown instead of starting a stretch drag.
|
||||
if grip_id == crate::app::visibility::VIS_GRIP_ID {
|
||||
|
||||
+50
-21
@@ -414,22 +414,24 @@ impl OpenCADStudio {
|
||||
None => tab.scene.active_model_tile_bounds(vw, vh),
|
||||
};
|
||||
let sel_h = tab.selected_handle;
|
||||
// The Current Vertex the Properties panel is focused on:
|
||||
// mark that grip hot so the navigated vertex is visible in
|
||||
// the drawing. Only for a single selected polyline, whose
|
||||
// vertex grips are ids 0..n. (Properties vertex stepper)
|
||||
// Mark the Properties panel's current point grip hot.
|
||||
let current_vertex_grip: Option<usize> = tab
|
||||
.properties
|
||||
.prop_vertex_indicator_active
|
||||
.then(|| sel_h)
|
||||
.flatten()
|
||||
.and_then(|h| {
|
||||
matches!(
|
||||
tab.scene.document.get_entity(h),
|
||||
Some(acadrust::EntityType::LwPolyline(_))
|
||||
| Some(acadrust::EntityType::Polyline2D(_))
|
||||
)
|
||||
.then_some(tab.properties.prop_vertex)
|
||||
let indexed = match tab.scene.document.get_entity(h) {
|
||||
Some(acadrust::EntityType::LwPolyline(_))
|
||||
| Some(acadrust::EntityType::Polyline2D(_)) => true,
|
||||
Some(acadrust::EntityType::Spline(spline)) => {
|
||||
!crate::entities::spline::uses_fit_method(spline)
|
||||
|| !spline.cv_frame_visible
|
||||
|| crate::entities::curve::spline_curve(spline).is_none()
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
indexed.then_some(tab.properties.prop_vertex)
|
||||
});
|
||||
// In-viewport grips are model-space; project them with the
|
||||
// viewport camera so they sit on the wire the GPU draws.
|
||||
@@ -495,6 +497,30 @@ impl OpenCADStudio {
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let control_polygon = tab.selected_handle.and_then(|handle| {
|
||||
let spline = match tab.scene.document.get_entity(handle) {
|
||||
Some(acadrust::EntityType::Spline(spline)) if spline.cv_frame_visible => spline,
|
||||
_ => return None,
|
||||
};
|
||||
if tab
|
||||
.selected_grip_handles
|
||||
.iter()
|
||||
.any(|owner| *owner != handle)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let points: Vec<_> = grips
|
||||
.iter()
|
||||
.filter(|grip| {
|
||||
grip.shape == crate::scene::model::object::GripShape::Circle
|
||||
})
|
||||
.map(|grip| grip.pos)
|
||||
.collect();
|
||||
(points.len() >= 2).then_some((
|
||||
points,
|
||||
spline.flags.closed || spline.flags.periodic,
|
||||
))
|
||||
});
|
||||
let grip_clip = if grips.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -688,6 +714,7 @@ impl OpenCADStudio {
|
||||
snap_ext_base,
|
||||
snap_ext_base2,
|
||||
grips,
|
||||
control_polygon,
|
||||
grip_clip,
|
||||
ucs_icons,
|
||||
ost_points,
|
||||
@@ -1092,26 +1119,28 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-functional grip popup (Phase 2). One bordered container
|
||||
// wraps a column of borderless item buttons so the popup reads
|
||||
// as a single widget instead of stacked tiles.
|
||||
if let Some(popup) = self.grip_popup.as_ref() {
|
||||
if !tab.is_start {
|
||||
// Size the row to the widest label so the selection
|
||||
// highlight fills the whole row instead of just the
|
||||
// text glyphs. ~7 px per character at size 12 + the
|
||||
// horizontal padding (10 + 10).
|
||||
let max_len = popup
|
||||
let labels: Vec<String> = popup
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.label.chars().count())
|
||||
.map(|item| {
|
||||
let (mark, raw) = item
|
||||
.label
|
||||
.strip_prefix("✓ ")
|
||||
.map_or(("", item.label), |raw| ("✓ ", raw));
|
||||
format!("{mark}{}", crate::i18n::translate(raw))
|
||||
})
|
||||
.collect();
|
||||
let max_len = labels
|
||||
.iter()
|
||||
.map(|label| label.chars().count())
|
||||
.max()
|
||||
.unwrap_or(8) as f32;
|
||||
let row_w = max_len * 7.0 + 24.0;
|
||||
let mut col = column![].spacing(0).width(iced::Length::Fixed(row_w));
|
||||
for (idx, item) in popup.items.iter().enumerate() {
|
||||
for (idx, label) in labels.into_iter().enumerate() {
|
||||
let is_sel = idx == popup.selected;
|
||||
let label = item.label;
|
||||
let btn = button(text(label).size(12))
|
||||
.on_press(Message::GripMenuPick(idx))
|
||||
.padding([3, 10])
|
||||
|
||||
Reference in new issue
Block a user