forked from 1b-agent/opencadstudio-upstream
Merge origin/main into plugin/host-model-api
Conflicts were additive: main's AddLayer/ModifyLayer (LayerConfig, OptHandle) alongside this branch's v7 system-variable, transaction, selection and solid operations. Main's variants keep their positions in the IPC enums; the v7 additions follow them. src/io/mod.rs takes main's reworded Shape-rotation note. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
6b8056cfeb
28 files changed
+1425
-97
No files matched your search
+151
-1
@@ -352,6 +352,14 @@ impl OpenCADStudio {
|
||||
let i = self.active_tab;
|
||||
let before = self.tabs[i].scene.document.entities().count();
|
||||
let error_revision = self.command_line.error_revision;
|
||||
// Surfaces that are already open belong to an earlier line (or
|
||||
// another tab, or startup). Only what *this* line left open is
|
||||
// reported as a blocker, otherwise a finished command would keep
|
||||
// answering `waiting_input` because of someone else's editor and
|
||||
// a caller polling for `completed` would never get there.
|
||||
let editor_before = self.text_inline.is_some();
|
||||
let mtext_before = self.mtext_editor.is_some();
|
||||
let modal_before = self.active_modal.is_some();
|
||||
if let Err(error) = self.run_headless(&cmd) {
|
||||
return err(error);
|
||||
}
|
||||
@@ -359,12 +367,32 @@ impl OpenCADStudio {
|
||||
return err(self.command_line.last_error.clone().unwrap_or_default());
|
||||
}
|
||||
let after = self.tabs[i].scene.document.entities().count();
|
||||
// A command can finish with an interactive surface still open:
|
||||
// the in-place text editor (the `TEXT` content step), the MTEXT
|
||||
// editor, or a modal. Reporting `completed` there hides the fact
|
||||
// that nothing was committed, so reuse the `waiting_input`
|
||||
// vocabulary and name the blocker.
|
||||
let blocked_by: Option<String> = if self.tabs[i].active_cmd.is_some() {
|
||||
Some("command".to_string())
|
||||
} else if let (false, Some(modal)) = (modal_before, self.active_modal.as_ref()) {
|
||||
Some(format!("modal:{modal:?}"))
|
||||
} else if !editor_before && self.text_inline.is_some() {
|
||||
Some("text_editor".to_string())
|
||||
} else if !mtext_before && self.mtext_editor.is_some() {
|
||||
Some("mtext_editor".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
json!({
|
||||
"ok": true,
|
||||
"cmd": cmd,
|
||||
"status": if self.tabs[i].active_cmd.is_some() { "waiting_input" } else { "completed" },
|
||||
"status": if blocked_by.is_some() { "waiting_input" } else { "completed" },
|
||||
"blocked_by": blocked_by,
|
||||
"entities": after,
|
||||
"added": after as i64 - before as i64,
|
||||
// Tokens no prompt ever asked for: leftover input is
|
||||
// reported rather than dropped on the floor.
|
||||
"unconsumed": self.command_line.unconsumed.clone(),
|
||||
})
|
||||
}
|
||||
"entities" => self.entity_summary(),
|
||||
@@ -814,6 +842,128 @@ mod tests {
|
||||
assert!(app.tabs[i].scene.last_tess_wires.get() > before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_text_line_creates_text_and_consumes_every_token() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let _ = app.automation_op(r#"{"op":"new"}"#);
|
||||
|
||||
// Regression: the content token used to be dropped once the in-place
|
||||
// editor took over, so this line created nothing while reporting ok.
|
||||
let r = app.automation_op(r#"{"op":"run","cmd":"TEXT 0,0 5 0 hello"}"#);
|
||||
assert_eq!(r["ok"], true);
|
||||
assert_eq!(r["status"], "completed", "{r}");
|
||||
assert_eq!(r["added"], 1, "{r}");
|
||||
assert_eq!(r["unconsumed"].as_array().map(|v| v.len()), Some(0), "{r}");
|
||||
|
||||
let counts = app.automation_op(r#"{"op":"entities"}"#);
|
||||
assert_eq!(counts["by_type"]["Text"], 1, "{counts}");
|
||||
assert_eq!(counts["total"], 1, "no phantom entity: {counts}");
|
||||
assert_eq!(assert_one_text(&app), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_word_text_keeps_the_spaces() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let _ = app.automation_op(r#"{"op":"new"}"#);
|
||||
|
||||
let r = app.automation_op(r#"{"op":"run","cmd":"TEXT 0,0 5 0 hello world"}"#);
|
||||
assert_eq!(r["status"], "completed", "{r}");
|
||||
assert_eq!(r["added"], 1, "{r}");
|
||||
assert_eq!(r["unconsumed"].as_array().map(|v| v.len()), Some(0), "{r}");
|
||||
let counts = app.automation_op(r#"{"op":"entities"}"#);
|
||||
assert_eq!(counts["by_type"]["Text"], 1, "{counts}");
|
||||
|
||||
// The spaces between the words have to survive the token split, and the
|
||||
// whole tail (not just its first token) has to land in the entity — an
|
||||
// off-by-one in the consumed index would still pass the checks above.
|
||||
assert_eq!(assert_one_text(&app), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stray_editor_is_not_hijacked_by_the_next_line() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let _ = app.automation_op(r#"{"op":"new"}"#);
|
||||
|
||||
// Line 1 stops at the content step and leaves the editor open.
|
||||
let r = app.automation_op(r#"{"op":"run","cmd":"TEXT 30,0 5 0"}"#);
|
||||
assert_eq!(r["blocked_by"], "text_editor", "{r}");
|
||||
|
||||
// Line 2 must not type its leftover token into that unrelated editor,
|
||||
// and — since line 2 did not open anything itself — it must not inherit
|
||||
// line 1's blocker either: a caller that polls for `completed` would
|
||||
// otherwise never get there.
|
||||
let r = app.automation_op(r#"{"op":"run","cmd":"CIRCLE 0,0 5 9"}"#);
|
||||
assert_eq!(r["added"], 1, "{r}");
|
||||
assert_eq!(r["unconsumed"][0], "9", "{r}");
|
||||
assert_eq!(r["blocked_by"], serde_json::Value::Null, "{r}");
|
||||
assert_eq!(r["status"], "completed", "{r}");
|
||||
let counts = app.automation_op(r#"{"op":"entities"}"#);
|
||||
assert_eq!(counts["by_type"]["Circle"], 1, "{counts}");
|
||||
assert!(
|
||||
counts["by_type"].get("Text").is_none(),
|
||||
"phantom text from stray editor: {counts}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_reports_leftover_tokens_instead_of_dropping_them() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let _ = app.automation_op(r#"{"op":"new"}"#);
|
||||
|
||||
// The radius step ends the command, so the extra token is never asked for.
|
||||
let r = app.automation_op(r#"{"op":"run","cmd":"CIRCLE 0,0 5 9"}"#);
|
||||
assert_eq!(r["added"], 1, "{r}");
|
||||
assert_eq!(r["unconsumed"][0], "9", "{r}");
|
||||
|
||||
// A fully consumed line reports nothing left over.
|
||||
let r = app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#);
|
||||
assert_eq!(r["unconsumed"].as_array().map(|v| v.len()), Some(0), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_without_content_reports_the_open_editor() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let _ = app.automation_op(r#"{"op":"new"}"#);
|
||||
|
||||
let r = app.automation_op(r#"{"op":"run","cmd":"TEXT 0,0 5 0"}"#);
|
||||
assert_eq!(r["status"], "waiting_input", "{r}");
|
||||
assert_eq!(r["blocked_by"], "text_editor", "{r}");
|
||||
let counts = app.automation_op(r#"{"op":"entities"}"#);
|
||||
assert_eq!(counts["total"], 0, "{counts}");
|
||||
|
||||
// The same editor can be finished through the control-surface messages.
|
||||
let _ = app.update(crate::app::Message::TextInlineInput("hello".into()));
|
||||
let _ = app.update(crate::app::Message::TextInlineOk);
|
||||
let counts = app.automation_op(r#"{"op":"entities"}"#);
|
||||
assert_eq!(counts["by_type"]["Text"], 1, "{counts}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_still_waiting_is_named_as_the_blocker() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let _ = app.automation_op(r#"{"op":"new"}"#);
|
||||
|
||||
let r = app.automation_op(r#"{"op":"run","cmd":"LINE"}"#);
|
||||
assert_eq!(r["status"], "waiting_input", "{r}");
|
||||
assert_eq!(r["blocked_by"], "command", "{r}");
|
||||
}
|
||||
|
||||
/// The text of the single `Text` entity in the drawing (panics otherwise).
|
||||
fn assert_one_text(app: &OpenCADStudio) -> String {
|
||||
let i = app.active_tab;
|
||||
let texts: Vec<String> = app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::EntityType::Text(t) => Some(t.value.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(texts.len(), 1, "expected exactly one Text entity, got {texts:?}");
|
||||
texts.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_ops_round_trip() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
|
||||
+101
-12
@@ -104,6 +104,7 @@ impl OpenCADStudio {
|
||||
self.snapper.from_point = None;
|
||||
self.snapper.clear_tracking();
|
||||
self.otrack_active = None;
|
||||
self.otrack_cross = None;
|
||||
self.otrack_kind = None;
|
||||
self.axis_lock_dir = None;
|
||||
self.dyn_user_reshaped = false;
|
||||
@@ -566,6 +567,7 @@ impl OpenCADStudio {
|
||||
/// the headless automation feeder so both behave identically.
|
||||
pub(super) fn run_command_line(&mut self, cmd: &str) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
self.command_line.unconsumed.clear();
|
||||
let tokens: Vec<&str> = cmd.split_whitespace().collect();
|
||||
if tokens.len() <= 1 {
|
||||
return self.dispatch_command(cmd);
|
||||
@@ -597,18 +599,64 @@ impl OpenCADStudio {
|
||||
/// Feed `tokens[1..]` to the active interactive command as points / option
|
||||
/// keywords, then terminate it as if Enter were pressed. No-op when no
|
||||
/// command is active.
|
||||
///
|
||||
/// Two things happen when the tokens run out mid-way, both needed so a
|
||||
/// headless caller can tell what actually happened:
|
||||
///
|
||||
/// * If the in-place text editor took over (the `TEXT` content step — the
|
||||
/// command itself has already ended by then, `active_cmd` is `None`), the
|
||||
/// remaining tokens are that text: they are typed into the editor and
|
||||
/// committed, the same messages the control surface's `text_input` /
|
||||
/// `text_commit` actions dispatch. Without this the tail of the line was
|
||||
/// silently dropped and `TEXT 0,0 5 0 hi` created nothing at all.
|
||||
/// * Anything still left over was never claimed by any prompt; it is
|
||||
/// recorded in [`CommandLine::unconsumed`] instead of vanishing.
|
||||
pub(super) fn finish_active_command(&mut self, tokens: &[String]) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
if self.tabs[i].active_cmd.is_none() {
|
||||
return Task::none();
|
||||
}
|
||||
self.last_point = None;
|
||||
// An editor that is *already* open belongs to someone else (an earlier
|
||||
// line that stopped at the content step). Only the editor this line
|
||||
// opens by feeding its own tokens may be handed the tail.
|
||||
let editor_was_open = self.text_inline.is_some();
|
||||
let mut tasks = Vec::new();
|
||||
// First token is the command verb itself; prompts start consuming after it.
|
||||
let mut consumed = 1;
|
||||
for tok in &tokens[1..] {
|
||||
if self.tabs[i].active_cmd.is_none() {
|
||||
break;
|
||||
}
|
||||
tasks.push(self.feed_active_cmd(tok));
|
||||
consumed += 1;
|
||||
}
|
||||
if !editor_was_open && self.text_inline.is_some() && consumed < tokens.len() {
|
||||
// Fill the editor the same way `Message::TextInlineInput` does, then
|
||||
// commit *synchronously* so the outcome is observable: dispatching
|
||||
// `TextInlineOk` as a task would hide whether the entity was really
|
||||
// created, and the tail token would be counted as consumed either
|
||||
// way — the caller would have no way to tell "text created" from
|
||||
// "text silently dropped".
|
||||
if let Some(editor) = self.text_inline.as_mut() {
|
||||
editor.value = tokens[consumed..].join(" ");
|
||||
}
|
||||
let committed = self.text_inline_commit();
|
||||
tasks.push(self.post_editor_closed(committed));
|
||||
if committed {
|
||||
consumed = tokens.len();
|
||||
// `TEXT` repeats by design: on a committed string it re-arms for
|
||||
// the next line (`open_next_line`), which re-opens the editor. A
|
||||
// batch line is a complete unit, and leaving that repeat armed
|
||||
// would make the *next* line get eaten as the next text position,
|
||||
// so end the command here, exactly as Esc does in the GUI.
|
||||
tasks.push(Task::done(Message::CommandEscape));
|
||||
}
|
||||
// On a failed commit the tokens stay unconsumed (assigned below), so
|
||||
// the caller still sees the text it passed in.
|
||||
}
|
||||
if consumed < tokens.len() {
|
||||
self.command_line.unconsumed = tokens[consumed..].to_vec();
|
||||
}
|
||||
tasks.push(self.feed_command(StepInput::Enter));
|
||||
Task::batch(tasks)
|
||||
@@ -1849,6 +1897,9 @@ impl OpenCADStudio {
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.is_some_and(|command| command.preserve_commit_layer());
|
||||
// Task produced by a command the arm dispatches; it must reach the
|
||||
// runtime or messages such as a chosen render mode are dropped.
|
||||
let mut dispatched = Task::none();
|
||||
match result {
|
||||
CmdResult::OpenAutoConstrainSettings => {
|
||||
self.auto_constrain_saved = Some(self.auto_constrain_settings.clone());
|
||||
@@ -4754,7 +4805,7 @@ impl OpenCADStudio {
|
||||
self.tabs[i].snap_result = None;
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
self.restore_pre_cmd_tangent();
|
||||
let _ = self.dispatch_command(&cmd);
|
||||
dispatched = self.dispatch_command(&cmd);
|
||||
}
|
||||
CmdResult::Dispatch(cmd) => {
|
||||
// End this interactive front-end, then run the assembled command
|
||||
@@ -4763,7 +4814,7 @@ impl OpenCADStudio {
|
||||
self.tabs[i].snap_result = None;
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
self.restore_pre_cmd_tangent();
|
||||
let _ = self.dispatch_command(&cmd);
|
||||
dispatched = self.dispatch_command(&cmd);
|
||||
}
|
||||
CmdResult::EditTableCell { handle, point } => {
|
||||
// TABLEDIT's pick: end the pick phase and hand (table, point)
|
||||
@@ -7494,16 +7545,15 @@ impl OpenCADStudio {
|
||||
// The rich text canvas owns keyboard editing itself. Leaving the
|
||||
// hidden command input focused would make it consume Left/Right before
|
||||
// the editor can handle them.
|
||||
if self.mtext_editor.is_some() {
|
||||
return self.unfocus_widgets();
|
||||
}
|
||||
// The in-place TEXT editor needs keyboard focus on its own field.
|
||||
if self.text_inline.is_some() {
|
||||
return iced::widget::operation::focus(iced::widget::Id::new(
|
||||
super::view::TEXT_INLINE_ID,
|
||||
));
|
||||
}
|
||||
self.focus_cmd_input()
|
||||
let focus = if self.mtext_editor.is_some() {
|
||||
self.unfocus_widgets()
|
||||
} else if self.text_inline.is_some() {
|
||||
// The in-place TEXT editor needs keyboard focus on its own field.
|
||||
iced::widget::operation::focus(iced::widget::Id::new(super::view::TEXT_INLINE_ID))
|
||||
} else {
|
||||
self.focus_cmd_input()
|
||||
};
|
||||
Task::batch([dispatched, focus])
|
||||
}
|
||||
|
||||
/// Restore the tangent-snap / ortho state that was in effect before the command started.
|
||||
@@ -10101,3 +10151,42 @@ mod thicken_tests {
|
||||
assert!(app.tabs[i].scene.document.get_entity(source).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dispatched_command_task_tests {
|
||||
use super::*;
|
||||
use crate::command::CmdResult;
|
||||
use acadrust::entities::ViewportRenderMode as Mode;
|
||||
|
||||
/// `CmdResult::Dispatch` and `CmdResult::Relaunch` run the assembled line
|
||||
/// through `dispatch_command`, and the Task it returns must reach the
|
||||
/// runtime: the render mode a VSCURRENT keyword picker chooses is applied
|
||||
/// by a message that Task carries.
|
||||
#[test]
|
||||
fn dispatch_and_relaunch_keep_the_task_the_command_returns() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let _ = app.automation_op(r#"{"op":"new"}"#);
|
||||
let i = app.active_tab;
|
||||
|
||||
app.tabs[i].render_mode = Mode::Wireframe2D;
|
||||
let task = app.apply_cmd_result(CmdResult::Dispatch("VSCURRENT GOURAUDSHADED".into()));
|
||||
app.drive_headless_task(task).unwrap();
|
||||
assert_eq!(
|
||||
app.tabs[i].render_mode,
|
||||
Mode::GouraudShaded,
|
||||
"Dispatch dropped the dispatched command's task"
|
||||
);
|
||||
|
||||
app.tabs[i].render_mode = Mode::Wireframe2D;
|
||||
let task = app.apply_cmd_result(CmdResult::Relaunch(
|
||||
"VSCURRENT GOURAUDSHADED".into(),
|
||||
Vec::new(),
|
||||
));
|
||||
app.drive_headless_task(task).unwrap();
|
||||
assert_eq!(
|
||||
app.tabs[i].render_mode,
|
||||
Mode::GouraudShaded,
|
||||
"Relaunch dropped the dispatched command's task"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -502,6 +502,11 @@ pub(super) struct OpenCADStudio {
|
||||
/// cursor is on a tracking ray. Lets a typed distance place a point along
|
||||
/// the ray from the tracking point (issue #69). `None` when not aligned.
|
||||
otrack_active: Option<(glam::DVec3, glam::DVec3)>,
|
||||
/// The vector `otrack_active` crosses, when the lock is the meeting of two
|
||||
/// tracking vectors. Drawn beside the first so the user can see that the
|
||||
/// point is their intersection; a typed distance still runs along
|
||||
/// `otrack_active` alone. `None` for a single-ray alignment. (#1313)
|
||||
otrack_cross: Option<(glam::DVec3, glam::DVec3)>,
|
||||
/// Active OTRACK ray kind, separate from typed-distance geometry.
|
||||
otrack_kind: Option<crate::snap::TrackingKind>,
|
||||
/// Whether Tangent snap was enabled before a tangent-pick command started.
|
||||
@@ -3873,6 +3878,7 @@ impl OpenCADStudio {
|
||||
annotation_auto_scale: -4,
|
||||
last_saved_config: None,
|
||||
otrack_active: None,
|
||||
otrack_cross: None,
|
||||
otrack_kind: None,
|
||||
clean_screen: false,
|
||||
quick_properties: false,
|
||||
|
||||
@@ -1048,6 +1048,130 @@ impl<'a> HostSession<'a> {
|
||||
pub fn push_error(&mut self, msg: &str) {
|
||||
self.app.command_line.push_error(msg);
|
||||
}
|
||||
|
||||
pub fn add_layer(&mut self, config: ocs_plugin_api::host::LayerConfig) -> Option<Handle> {
|
||||
let trimmed = config.name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let doc = self.document_mut();
|
||||
if doc.layers.contains(trimmed) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let resolved_lt = match config.linetype {
|
||||
// A name the drawing does not carry would leave the LAYER record
|
||||
// pointing at no LTYPE, so it is refused rather than written.
|
||||
Some(ref lt_name) => match resolve_linetype(doc, lt_name) {
|
||||
Some(name) => name,
|
||||
None => return None,
|
||||
},
|
||||
None => "Continuous".to_string(),
|
||||
};
|
||||
|
||||
let mut layer = acadrust::tables::Layer::new(trimmed);
|
||||
let handle = doc.allocate_handle();
|
||||
layer.handle = handle;
|
||||
layer.color = config.color.unwrap_or(acadrust::types::Color::Index(7));
|
||||
layer.line_type = resolved_lt;
|
||||
layer.line_weight = config.lineweight.unwrap_or(acadrust::types::LineWeight::ByLayer);
|
||||
layer.flags.off = config.off.unwrap_or(false);
|
||||
if let Some(frz) = config.frozen {
|
||||
if frz {
|
||||
layer.freeze();
|
||||
} else {
|
||||
layer.thaw();
|
||||
}
|
||||
}
|
||||
layer.flags.locked = config.locked.unwrap_or(false);
|
||||
layer.is_plottable = config.plottable.unwrap_or(true);
|
||||
layer.transparency = config.transparency.unwrap_or(acadrust::types::Transparency::ByLayer);
|
||||
layer.description = config.description.unwrap_or_default();
|
||||
|
||||
let _ = doc.layers.add(layer);
|
||||
|
||||
self.app.tabs[self.tab].dirty = true;
|
||||
self.app.tabs[self.tab]
|
||||
.scene
|
||||
.invalidate_layer_dependencies(&[trimmed.to_string()]);
|
||||
self.app.refresh_layer_panel();
|
||||
self.publish_document_view();
|
||||
Some(handle)
|
||||
}
|
||||
|
||||
pub fn modify_layer(&mut self, config: ocs_plugin_api::host::LayerConfig) -> bool {
|
||||
let trimmed = config.name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let resolved_lt = match config.linetype {
|
||||
Some(ref lt_name) => match resolve_linetype(self.document_mut(), lt_name) {
|
||||
Some(name) => Some(name),
|
||||
None => return false,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let doc = self.document_mut();
|
||||
let Some(existing) = doc.layers.get_mut(trimmed) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Some(c) = config.color {
|
||||
existing.color = c;
|
||||
existing.color_name = None;
|
||||
existing.book_name = None;
|
||||
}
|
||||
if let Some(lt) = resolved_lt {
|
||||
existing.line_type = lt;
|
||||
}
|
||||
if let Some(lw) = config.lineweight {
|
||||
existing.line_weight = lw;
|
||||
}
|
||||
if let Some(off) = config.off {
|
||||
existing.flags.off = off;
|
||||
}
|
||||
if let Some(frz) = config.frozen {
|
||||
if frz {
|
||||
existing.freeze();
|
||||
} else {
|
||||
existing.thaw();
|
||||
}
|
||||
}
|
||||
if let Some(lck) = config.locked {
|
||||
existing.flags.locked = lck;
|
||||
}
|
||||
if let Some(plt) = config.plottable {
|
||||
existing.is_plottable = plt;
|
||||
}
|
||||
if let Some(tr) = config.transparency {
|
||||
existing.transparency = tr;
|
||||
}
|
||||
if let Some(desc) = config.description {
|
||||
existing.description = desc;
|
||||
}
|
||||
|
||||
self.app.tabs[self.tab].dirty = true;
|
||||
self.app.tabs[self.tab]
|
||||
.scene
|
||||
.invalidate_layer_dependencies(&[trimmed.to_string()]);
|
||||
self.app.refresh_layer_panel();
|
||||
self.publish_document_view();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored spelling of `name` in the drawing's linetype table, loading the
|
||||
/// standard linetypes first when it is not there yet. `None` when the drawing
|
||||
/// cannot supply it: a layer must not reference a linetype that does not exist.
|
||||
fn resolve_linetype(doc: &mut acadrust::CadDocument, name: &str) -> Option<String> {
|
||||
let stored = |doc: &acadrust::CadDocument| doc.line_types.get(name).map(|lt| lt.name.clone());
|
||||
stored(doc).or_else(|| {
|
||||
crate::io::linetypes::populate_document(doc);
|
||||
stored(doc)
|
||||
})
|
||||
}
|
||||
|
||||
/// The stable contract a plugin's `dispatch` sees. Each method forwards to the
|
||||
@@ -1180,6 +1304,12 @@ impl HostApi for HostSession<'_> {
|
||||
fn close_document_view_v4(&mut self, tab_id: u64) {
|
||||
self.close_document_view_v4(tab_id)
|
||||
}
|
||||
fn add_layer(&mut self, config: ocs_plugin_api::host::LayerConfig) -> Option<Handle> {
|
||||
self.add_layer(config)
|
||||
}
|
||||
fn modify_layer(&mut self, config: ocs_plugin_api::host::LayerConfig) -> bool {
|
||||
self.modify_layer(config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand)
|
||||
@@ -6891,4 +7021,63 @@ mod tests {
|
||||
assert_eq!(got.values.len(), 1);
|
||||
assert!(matches!(got.values[0], XDataValue::Integer32(123)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_add_layer_with_defaults_and_modify() {
|
||||
use ocs_plugin_api::host::LayerConfig;
|
||||
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
|
||||
// 1. Add layer with minimal config (only name specified)
|
||||
let config = LayerConfig {
|
||||
name: "ELECTRICAL".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let handle = host.add_layer(config).expect("should create layer");
|
||||
assert_ne!(handle, acadrust::Handle::NULL);
|
||||
|
||||
// Verify defaults were applied
|
||||
let layer = host.document().layers.get("ELECTRICAL").expect("layer should exist");
|
||||
assert_eq!(layer.color, acadrust::types::Color::Index(7));
|
||||
assert_eq!(layer.line_type, "Continuous");
|
||||
assert_eq!(layer.line_weight, acadrust::types::LineWeight::ByLayer);
|
||||
assert!(!layer.flags.off);
|
||||
assert!(!layer.flags.frozen);
|
||||
assert!(!layer.flags.locked);
|
||||
assert!(layer.is_plottable);
|
||||
|
||||
// 2. Duplicate add_layer should be rejected (return None)
|
||||
let dup_config = LayerConfig {
|
||||
name: "ELECTRICAL".to_string(),
|
||||
color: Some(acadrust::types::Color::Index(1)),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(host.add_layer(dup_config).is_none(), "duplicate layer should return None");
|
||||
|
||||
// 3. Modify only color and locked; other properties should remain untouched
|
||||
let mod_config = LayerConfig {
|
||||
name: "ELECTRICAL".to_string(),
|
||||
color: Some(acadrust::types::Color::Index(1)),
|
||||
locked: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(host.modify_layer(mod_config));
|
||||
|
||||
let updated = host.document().layers.get("ELECTRICAL").expect("layer should exist");
|
||||
assert_eq!(updated.color, acadrust::types::Color::Index(1)); // Modified to red
|
||||
assert!(updated.flags.locked); // Modified to locked
|
||||
assert_eq!(updated.line_type, "Continuous"); // Kept as-is
|
||||
assert_eq!(updated.line_weight, acadrust::types::LineWeight::ByLayer); // Kept as-is
|
||||
assert!(!updated.flags.off); // Kept as-is
|
||||
|
||||
// 4. Modify nonexistent layer returns false
|
||||
let non_existent = LayerConfig {
|
||||
name: "DOES_NOT_EXIST".to_string(),
|
||||
color: Some(acadrust::types::Color::Index(2)),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!host.modify_layer(non_existent));
|
||||
}
|
||||
}
|
||||
@@ -2683,6 +2683,13 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={}
|
||||
annotation_scale_handle,
|
||||
);
|
||||
let mut entity_grips = dispatch::grips(contextual.as_ref());
|
||||
if let acadrust::EntityType::Insert(insert) = contextual.as_ref() {
|
||||
entity_grips = crate::entities::insert::visible_attribute_grips(
|
||||
&self.tabs[i].scene.document,
|
||||
insert,
|
||||
self.tabs[i].scene.annotation_scale,
|
||||
);
|
||||
}
|
||||
if crate::scene::model::solid_history::has_specialized_primitive_properties(
|
||||
&self.tabs[i].scene.document,
|
||||
handle,
|
||||
|
||||
@@ -401,6 +401,7 @@ impl OpenCADStudio {
|
||||
{
|
||||
self.snapper.clear_tracking();
|
||||
self.otrack_active = None;
|
||||
self.otrack_cross = None;
|
||||
self.otrack_kind = None;
|
||||
}
|
||||
if let Some((started, tab_id, before)) = perf_edit_before {
|
||||
@@ -437,6 +438,7 @@ impl OpenCADStudio {
|
||||
pub(in crate::app) fn reset_tracking_after_point(&mut self) {
|
||||
self.snapper.clear_tracking();
|
||||
self.otrack_active = None;
|
||||
self.otrack_cross = None;
|
||||
self.otrack_kind = None;
|
||||
}
|
||||
|
||||
@@ -4017,6 +4019,7 @@ impl OpenCADStudio {
|
||||
if !self.snapper.otrack_enabled {
|
||||
self.snapper.clear_tracking();
|
||||
self.otrack_active = None;
|
||||
self.otrack_cross = None;
|
||||
self.otrack_kind = None;
|
||||
}
|
||||
Task::none()
|
||||
|
||||
@@ -1479,6 +1479,7 @@ impl OpenCADStudio {
|
||||
Some((base, dir))
|
||||
});
|
||||
self.otrack_active = otrack_hit.map(|hit| (hit.base, hit.dir)).or(drafting_guide);
|
||||
self.otrack_cross = otrack_hit.and_then(|hit| hit.cross);
|
||||
|
||||
self.otrack_kind = otrack_hit.map(|hit| hit.kind);
|
||||
|
||||
@@ -2190,6 +2191,7 @@ impl OpenCADStudio {
|
||||
None
|
||||
};
|
||||
self.otrack_active = otrack_hit.map(|h| (h.base, h.dir));
|
||||
self.otrack_cross = otrack_hit.and_then(|h| h.cross);
|
||||
self.otrack_kind = otrack_hit.map(|h| h.kind);
|
||||
|
||||
// Parallel snap: with nothing else snapped or tracked, lock
|
||||
@@ -2208,6 +2210,7 @@ impl OpenCADStudio {
|
||||
(self.last_point, self.snapper.parallel_ref)
|
||||
{
|
||||
self.otrack_active = Some((base, dir.as_dvec3()));
|
||||
self.otrack_cross = None;
|
||||
}
|
||||
self.tabs[i].snap_result = Some(par);
|
||||
}
|
||||
|
||||
+21
-10
@@ -770,15 +770,26 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
// The active alignment vector: a dashed guide from the acquired
|
||||
// tracking point through the locked cursor, so the user sees the
|
||||
// extension / tracking line they are snapped to (#219).
|
||||
let otrack_line: Option<(iced::Point, iced::Point)> =
|
||||
match (otrack_proj, self.otrack_active) {
|
||||
(Some((view_rot, eye, ob)), Some((base, _dir))) => {
|
||||
let b = ost_project(base, view_rot, eye, ob);
|
||||
let a = ost_project(tab.last_cursor_world, view_rot, eye, ob);
|
||||
(b.x.is_finite() && a.x.is_finite()).then_some((b, a))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// An intersection lock is the meeting of two tracking vectors, so
|
||||
// both are drawn — one guide alone hides what the point is (#1313).
|
||||
let otrack_lines: Vec<(iced::Point, iced::Point)> = match otrack_proj {
|
||||
Some((view_rot, eye, ob)) => {
|
||||
let a = ost_project(tab.last_cursor_world, view_rot, eye, ob);
|
||||
self.otrack_active
|
||||
.into_iter()
|
||||
.chain(self.otrack_cross)
|
||||
.filter_map(|(base, _dir)| {
|
||||
let b = ost_project(base, view_rot, eye, ob);
|
||||
(b.x.is_finite()
|
||||
&& b.y.is_finite()
|
||||
&& a.x.is_finite()
|
||||
&& a.y.is_finite())
|
||||
.then_some((b, a))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
// The acquired Parallel-snap reference, marked on its line (#277).
|
||||
let parallel_ref_marker: Option<iced::Point> =
|
||||
match (otrack_proj, self.snapper.parallel_ref) {
|
||||
@@ -894,7 +905,7 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
grip_clip,
|
||||
ucs_icons,
|
||||
ost_points,
|
||||
otrack_line,
|
||||
otrack_lines,
|
||||
parallel_ref_marker,
|
||||
// ViewCube hover region matches the drawn cube — gone when hidden.
|
||||
!is_paper && viewcube_visible,
|
||||
|
||||
Reference in new issue
Block a user