forked from 1b-agent/opencadstudio-upstream
Add BACTIONSET and the Block Editor Action Selection ribbon entry. Pick or preselect an action, replace/add/remove its drawing targets, or redefine a Stretch frame while preserving the native action and graph identity. Retain existing Stretch vertex bindings during target edits and bind new targets against the stored frame. Allow removing invalid bindings or rebuilding them through a replacement frame. Rebuild evaluated references at their independent cached values so removed targets lose old action effects, with local undo, discard and one drawing save delta. Validate synthetic native DWG/DXF roundtrips, shared COPY representations, stale-result guards, reference geometry refresh, frame/cancel/history and the real ribbon flyout. 347 block tests pass (6 existing opt-in ignored), plus 42 dock and 27 viewport tests.
244 lines
8.3 KiB
Rust
244 lines
8.3 KiB
Rust
//! Edit native action selections without replacing their graph nodes.
|
|
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, PointPickContext};
|
|
use crate::scene::block_actions::{self as actions, Kind};
|
|
use codec::objects::ObjectType;
|
|
use codec::{CadDocument, Handle};
|
|
use glam::DVec3;
|
|
|
|
inventory::submit!(crate::command::CommandRegistration {
|
|
names: &["BACTIONSET"]
|
|
});
|
|
|
|
struct Candidate {
|
|
handle: Handle,
|
|
name: String,
|
|
at: DVec3,
|
|
targets: Vec<Handle>,
|
|
stretch: bool,
|
|
expected: ObjectType,
|
|
}
|
|
#[derive(Clone, Copy)]
|
|
enum Mode {
|
|
New,
|
|
Add,
|
|
Remove,
|
|
Frame,
|
|
}
|
|
pub(crate) struct ActionSetCommand {
|
|
document: u64,
|
|
definition: Handle,
|
|
candidates: Vec<Candidate>,
|
|
chosen: Option<usize>,
|
|
mode: Option<Mode>,
|
|
picked: Vec<Handle>,
|
|
frame: Vec<DVec3>,
|
|
context: Option<PointPickContext>,
|
|
}
|
|
impl ActionSetCommand {
|
|
pub(crate) fn new(
|
|
document: u64,
|
|
definition: Handle,
|
|
doc: &CadDocument,
|
|
selected: Option<Handle>,
|
|
) -> Self {
|
|
let candidates = actions::actions(doc, definition)
|
|
.into_iter()
|
|
.map(|(handle, a, kind, _)| Candidate {
|
|
handle,
|
|
name: a.element.name.clone(),
|
|
at: actions::v(a.display_location),
|
|
targets: a.dependencies.clone(),
|
|
stretch: kind == Kind::Stretch,
|
|
expected: doc.objects[&handle].clone(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let chosen = candidates.iter().position(|c| Some(c.handle) == selected);
|
|
Self {
|
|
document,
|
|
definition,
|
|
candidates,
|
|
chosen,
|
|
mode: None,
|
|
picked: vec![],
|
|
frame: vec![],
|
|
context: None,
|
|
}
|
|
}
|
|
fn finish(&self) -> CmdResult {
|
|
let c = &self.candidates[self.chosen.unwrap()];
|
|
let targets = match self.mode.unwrap() {
|
|
Mode::New => self.picked.clone(),
|
|
Mode::Add => c.targets.iter().chain(&self.picked).copied().collect(),
|
|
Mode::Remove => c
|
|
.targets
|
|
.iter()
|
|
.copied()
|
|
.filter(|h| !self.picked.contains(h))
|
|
.collect(),
|
|
Mode::Frame => c.targets.clone(),
|
|
};
|
|
CmdResult::EditBlockActionSet {
|
|
document: self.document,
|
|
definition: self.definition,
|
|
action: c.handle,
|
|
expected: Box::new(c.expected.clone()),
|
|
targets,
|
|
frame: (self.frame.len() == 2).then(|| [self.frame[0], self.frame[1]]),
|
|
}
|
|
}
|
|
}
|
|
impl CadCommand for ActionSetCommand {
|
|
fn name(&self) -> &'static str {
|
|
"BACTIONSET"
|
|
}
|
|
fn input_kind(&self) -> InputKind {
|
|
if self.chosen.is_none() {
|
|
InputKind::FreeText
|
|
} else if self.mode.is_none() {
|
|
InputKind::SingleToken
|
|
} else {
|
|
InputKind::Point
|
|
}
|
|
}
|
|
fn prompt(&self) -> String {
|
|
crate::t!(if self.chosen.is_none() {
|
|
"BACTIONSET Pick an action marker or enter its name:"
|
|
} else if self.mode.is_none() && self.candidates[self.chosen.unwrap()].stretch {
|
|
"BACTIONSET [New/Add/Remove/Frame] <New>:"
|
|
} else if self.mode.is_none() {
|
|
"BACTIONSET [New/Add/Remove] <New>:"
|
|
} else if matches!(self.mode, Some(Mode::Frame)) && self.frame.is_empty() {
|
|
"BACTIONSET Specify first corner of replacement stretch frame:"
|
|
} else if matches!(self.mode, Some(Mode::Frame)) {
|
|
"BACTIONSET Specify opposite corner of replacement stretch frame:"
|
|
} else {
|
|
"BACTIONSET Select objects, then press Enter:"
|
|
})
|
|
.into_owned()
|
|
}
|
|
fn options(&self) -> Vec<CmdOption> {
|
|
if self.chosen.is_some() && self.mode.is_none() {
|
|
let mut options = vec![
|
|
CmdOption::new("New", "New"),
|
|
CmdOption::new("Add", "Add"),
|
|
CmdOption::new("Remove", "Remove"),
|
|
];
|
|
if self.candidates[self.chosen.unwrap()].stretch {
|
|
options.push(CmdOption::new("Frame", "Frame"));
|
|
}
|
|
options
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
|
if self.chosen.is_none() {
|
|
let matches = self
|
|
.candidates
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, c)| c.name.eq_ignore_ascii_case(text))
|
|
.map(|(i, _)| i)
|
|
.collect::<Vec<_>>();
|
|
return Some(if matches.len() == 1 {
|
|
self.chosen = Some(matches[0]);
|
|
CmdResult::NeedPoint
|
|
} else {
|
|
CmdResult::ReportError(
|
|
crate::t!("Enter a unique action name or pick its marker.").into_owned(),
|
|
)
|
|
});
|
|
}
|
|
if self.mode.is_none() {
|
|
let mode = match text.to_ascii_uppercase().as_str() {
|
|
"NEW" | "N" => Mode::New,
|
|
"ADD" | "A" => Mode::Add,
|
|
"REMOVE" | "R" => Mode::Remove,
|
|
"FRAME" | "F" if self.candidates[self.chosen.unwrap()].stretch => Mode::Frame,
|
|
_ => {
|
|
return Some(CmdResult::ReportError(
|
|
crate::t!("Choose an available selection-editing option.").into_owned(),
|
|
))
|
|
}
|
|
};
|
|
self.mode = Some(mode);
|
|
return Some(CmdResult::DeselectAndContinue);
|
|
}
|
|
None
|
|
}
|
|
fn wants_point_pick_context(&self) -> bool {
|
|
self.chosen.is_none()
|
|
}
|
|
fn set_point_pick_context(&mut self, context: Option<PointPickContext>) {
|
|
self.context = context;
|
|
}
|
|
fn on_point(&mut self, point: DVec3) -> CmdResult {
|
|
if self.chosen.is_none() {
|
|
let Some(c) = self.context else {
|
|
return CmdResult::ReportError(crate::t!("Enter the action name.").into_owned());
|
|
};
|
|
let project =
|
|
|p| crate::scene::pick::hit_test::world_to_screen(p, c.view, c.eye, c.bounds);
|
|
let at = project(point);
|
|
if let Some((i, _)) = self
|
|
.candidates
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, a)| (i, project(a.at).distance(at)))
|
|
.filter(|(_, d)| d.is_finite() && *d <= c.aperture_px.max(10.))
|
|
.min_by(|(_, a), (_, b)| a.total_cmp(b))
|
|
{
|
|
self.chosen = Some(i);
|
|
return CmdResult::NeedPoint;
|
|
}
|
|
return CmdResult::ReportError(crate::t!("Pick an action marker.").into_owned());
|
|
}
|
|
if matches!(self.mode, Some(Mode::Frame)) {
|
|
let plane = self.candidates[self.chosen.unwrap()].at.z;
|
|
if !point.is_finite() || (point.z - plane).abs() > 1e-8 {
|
|
return CmdResult::ReportError(
|
|
crate::t!("Specify a finite corner in the action's XY plane.").into_owned(),
|
|
);
|
|
}
|
|
if self
|
|
.frame
|
|
.first()
|
|
.is_some_and(|a| (a.x - point.x).abs() < 1e-9 || (a.y - point.y).abs() < 1e-9)
|
|
{
|
|
return CmdResult::ReportError(
|
|
crate::t!("The stretch frame needs a nonzero width and height.").into_owned(),
|
|
);
|
|
}
|
|
self.frame.truncate(1);
|
|
self.frame.push(point);
|
|
if self.frame.len() == 2 {
|
|
return self.finish();
|
|
}
|
|
}
|
|
CmdResult::NeedPoint
|
|
}
|
|
fn is_selection_gathering(&self) -> bool {
|
|
matches!(self.mode, Some(Mode::New | Mode::Add | Mode::Remove))
|
|
}
|
|
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
|
|
self.picked = handles;
|
|
CmdResult::NeedPoint
|
|
}
|
|
fn on_enter(&mut self) -> CmdResult {
|
|
if self.chosen.is_none() {
|
|
return CmdResult::Cancel;
|
|
}
|
|
if self.mode.is_none() {
|
|
return self.on_text_input("New").unwrap();
|
|
}
|
|
if self.is_selection_gathering() {
|
|
self.finish()
|
|
} else {
|
|
CmdResult::Cancel
|
|
}
|
|
}
|
|
fn on_preview_wires(&mut self, point: DVec3) -> Vec<crate::scene::WireModel> {
|
|
super::action::frame_preview(&self.frame, point)
|
|
}
|
|
}
|