forked from 1b-agent/opencadstudio-upstream
Merge pull request #1423 from felixriestra:pr/python-host-model
The bundled `opencad.python` plugin drives OCS through plugin API v7: all 43 canvas entity kinds, the drawing tables, and OCS's own commands run step by step. RustPython stays in the plugin process — the plugin crate is outside the workspace with its own lock file, so the application's own dependencies are unchanged. Reconciled with the plugin command work that landed as #1425, which the branch was written before: - Both added `impl Drop for PluginProcessInteractiveAdapter`, which git merges silently into two impls that do not compile. One body now cancels the command and then drops it from the runner, in that order: a plugin waiting on a step has to be released before the runner forgets the command. - `execute_command` (#1425) and `run_command` (#1423) are both routes from a script to the command line, and only the second was guarded. The SCRIPTCOMMANDS switch and the denylist are shared now, so neither route is the unguarded one. - `run_command` dispatched with plugin dispatch still enabled, so a script naming a plugin command re-entered the plugin that was blocked waiting for it. It sets the suppression flag for the length of the dispatch, as `execute_command` does. The denylist read the command as typed, while the dispatcher resolves aliases: a user-defined `Q` for `QUIT` (ALIASEDIT writes those) walked past it. It resolves the alias first. Also: the active-command comparison ran on every message, a mouse move included, allocating two strings for names that are already `&'static str`; SCRIPTCOMMANDS' own messages go through the translation macros; and `docs/.DS_Store` (now ignored) and an unrelated RTF note about constraint commands are out of the tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
41ecc0c458
72 files changed
+30614
-91
No files matched your search
@@ -283,6 +283,8 @@ impl OpenCADStudio {
|
||||
// selection check has to run on this path too.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.notify_plugins_selection_changed();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.notify_plugins_document_changed();
|
||||
res
|
||||
}
|
||||
|
||||
@@ -767,6 +769,46 @@ impl OpenCADStudio {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn clayer_command_sets_layer_used_by_new_geometry() {
|
||||
let mut app = super::OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
app.automation_op(r#"{"op":"run","cmd":"LAYER NEW Annotations"}"#);
|
||||
app.automation_op(r#"{"op":"run","cmd":"CLAYER Annotations"}"#);
|
||||
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#);
|
||||
let lines = app.automation_op(r#"{"op":"query","type":"Line"}"#);
|
||||
assert_eq!(lines["entities"][0]["layer"], "Annotations");
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn built_in_edits_advance_plugin_document_fingerprint_once() {
|
||||
let mut app = super::OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let before = app.last_plugin_document.expect("new drawing published");
|
||||
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#);
|
||||
let after = app.last_plugin_document.expect("line edit published");
|
||||
assert_eq!(after.0, app.tabs[app.active_tab].id);
|
||||
assert_ne!(after, before);
|
||||
assert_eq!(after.1, app.tabs[app.active_tab].scene.geometry_epoch);
|
||||
app.automation_op(r#"{"op":"query","type":"Line"}"#);
|
||||
assert_eq!(app.last_plugin_document, Some(after));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn plugin_edit_publication_is_not_repeated_at_message_boundary() {
|
||||
use acadrust::entities::Point;
|
||||
use acadrust::EntityType;
|
||||
|
||||
let mut app = super::OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let tab = app.active_tab;
|
||||
let mut host = super::super::plugin_host::HostSession::new(&mut app, tab);
|
||||
host.add_entity(EntityType::Point(Point::new()));
|
||||
let published = app.last_plugin_document.expect("plugin write published");
|
||||
app.notify_plugins_document_changed();
|
||||
assert_eq!(app.last_plugin_document, Some(published));
|
||||
}
|
||||
use crate::app::OpenCADStudio;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2745,21 +2745,7 @@ impl OpenCADStudio {
|
||||
self.command_line
|
||||
.push_output(crate::tf!("CLAYER = \"{cur}\"").as_ref());
|
||||
} else {
|
||||
if self.tabs[i].scene.document.layers.contains(name_arg) {
|
||||
let handle = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.get(name_arg)
|
||||
.map(|l| l.handle)
|
||||
.unwrap_or(acadrust::types::Handle::NULL);
|
||||
self.tabs[i].scene.document.header.current_layer_name =
|
||||
name_arg.to_string();
|
||||
self.tabs[i].scene.document.header.current_layer_handle = handle;
|
||||
self.tabs[i].active_layer = name_arg.to_string();
|
||||
self.tabs[i].layers.current_layer = name_arg.to_string();
|
||||
self.ribbon.active_layer = name_arg.to_string();
|
||||
self.tabs[i].dirty = true;
|
||||
if self.set_current_layer_name(i, name_arg).is_ok() {
|
||||
self.command_line
|
||||
.push_output(crate::tf!("CLAYER set to \"{name_arg}\"").as_ref());
|
||||
} else {
|
||||
@@ -2944,6 +2930,29 @@ impl OpenCADStudio {
|
||||
_ => self.command_line.push_error(crate::t!("Requires 0 or 1").as_ref()),
|
||||
}
|
||||
}
|
||||
cmd if cmd == "SCRIPTCOMMANDS" || cmd.starts_with("SCRIPTCOMMANDS ") => {
|
||||
// A user setting, deliberately outside the script's reach: it is refused by
|
||||
// the script command runner, so only the command line can change it.
|
||||
let arg = cmd.trim_start_matches("SCRIPTCOMMANDS").trim();
|
||||
match arg {
|
||||
"" => {}
|
||||
"1" | "ON" | "YES" => self.script_commands = true,
|
||||
"0" | "OFF" | "NO" => self.script_commands = false,
|
||||
_ => {
|
||||
self.command_line.push_error(
|
||||
crate::t!("SCRIPTCOMMANDS takes 1 (scripts may run commands) or 0 (they may not)")
|
||||
.as_ref(),
|
||||
);
|
||||
return Some(Task::none());
|
||||
}
|
||||
}
|
||||
if !arg.is_empty() {
|
||||
self.persist_settings_if_changed();
|
||||
}
|
||||
let state = if self.script_commands { 1 } else { 0 };
|
||||
self.command_line
|
||||
.push_output(crate::tf!("SCRIPTCOMMANDS = {state}").as_ref());
|
||||
}
|
||||
"SAVETIME" => {
|
||||
use crate::command::ValuePromptCommand;
|
||||
let c = ValuePromptCommand::new(
|
||||
|
||||
@@ -314,7 +314,7 @@ fn command_selection_policy(name: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value {
|
||||
pub(super) fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value {
|
||||
let options = command.options();
|
||||
let mut accepts = Vec::new();
|
||||
if command.is_selection_gathering() {
|
||||
|
||||
@@ -2,6 +2,27 @@ use super::OpenCADStudio;
|
||||
use crate::ui;
|
||||
|
||||
impl OpenCADStudio {
|
||||
/// Apply the current layer to the document header and all per-tab/UI
|
||||
/// creation state. Both built-in commands and plugin settings use this.
|
||||
pub(super) fn set_current_layer_name(&mut self, tab: usize, layer: &str) -> Result<(), String> {
|
||||
let handle = self.tabs[tab]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.get(layer)
|
||||
.map(|entry| entry.handle)
|
||||
.ok_or_else(|| format!("layer {layer:?} does not exist"))?;
|
||||
self.tabs[tab].scene.document.header.current_layer_name = layer.to_owned();
|
||||
self.tabs[tab].scene.document.header.current_layer_handle = handle;
|
||||
self.tabs[tab].active_layer = layer.to_owned();
|
||||
self.tabs[tab].layers.current_layer = layer.to_owned();
|
||||
self.tabs[tab].dirty = true;
|
||||
if tab == self.active_tab {
|
||||
self.ribbon.active_layer = layer.to_owned();
|
||||
self.refresh_layer_panel();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn load_layer_state_editor(&mut self, selected: Option<String>) {
|
||||
let i = self.active_tab;
|
||||
if let Some(name) = selected {
|
||||
|
||||
+11
-1
@@ -35,7 +35,7 @@ mod record_api;
|
||||
pub(crate) mod settings;
|
||||
mod shortcuts;
|
||||
mod startup;
|
||||
mod style_ops;
|
||||
pub(crate) mod style_ops;
|
||||
mod text_inline;
|
||||
mod tolerance_dialog;
|
||||
mod update;
|
||||
@@ -634,6 +634,8 @@ pub(super) struct OpenCADStudio {
|
||||
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command);
|
||||
/// 0 disables autosave.
|
||||
pub savetime_min: i32,
|
||||
/// SCRIPTCOMMANDS: scripts may run OCS commands (see `UserSettings`).
|
||||
pub script_commands: bool,
|
||||
/// Persisted default viewport background, restored from settings and applied
|
||||
/// to every drawing tab (new and opened) so a chosen background survives
|
||||
/// restarts (#188). `None` = the built-in dark-grey / off-white defaults.
|
||||
@@ -904,6 +906,11 @@ pub(super) struct OpenCADStudio {
|
||||
/// `SelectionChangedV4` fires once per real change rather than per message.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_plugin_selection: Option<(u64, u64)>,
|
||||
/// `(tab id, geometry epoch)` last published to the V4 document view.
|
||||
/// Built-in edits bypass HostSession, so this is checked at message
|
||||
/// boundaries as well as after plugin-initiated writes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_plugin_document: Option<(u64, u64)>,
|
||||
/// External add-on packages found in the plugins folder, refreshed when the
|
||||
/// Plugin Manager opens.
|
||||
external_plugins: Vec<crate::plugin::external::ExternalPlugin>,
|
||||
@@ -4000,6 +4007,7 @@ impl OpenCADStudio {
|
||||
suppress_plugin_dispatch: false,
|
||||
constraint_bar_mode: 4095,
|
||||
savetime_min: 10,
|
||||
script_commands: true,
|
||||
default_bg_color: None,
|
||||
default_paper_bg_color: None,
|
||||
cliprompt_lines: 3,
|
||||
@@ -4101,6 +4109,8 @@ impl OpenCADStudio {
|
||||
disabled_plugins: rustc_hash::FxHashSet::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_plugin_selection: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_plugin_document: None,
|
||||
external_plugins: Vec::new(),
|
||||
loaded_plugin_ids: rustc_hash::FxHashSet::default(),
|
||||
plugin_load_errors: rustc_hash::FxHashMap::default(),
|
||||
|
||||
+8336
-22
File diff suppressed because it is too large.
Load diff
@@ -485,6 +485,10 @@ pub struct UserSettings {
|
||||
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command).
|
||||
/// 0 disables autosave.
|
||||
pub savetime_min: i32,
|
||||
/// SCRIPTCOMMANDS: whether a script (the Python plugin) may run OCS commands
|
||||
/// through the host. On by default; a user turns it off with the
|
||||
/// `SCRIPTCOMMANDS 0` command, and a script cannot change it.
|
||||
pub script_commands: bool,
|
||||
/// File type and version used when a new/unsaved drawing is first saved.
|
||||
/// Existing drawings keep their own type and version.
|
||||
pub default_save_format: String,
|
||||
@@ -685,6 +689,7 @@ impl Default for UserSettings {
|
||||
constraint_bar_display: 3,
|
||||
constraint_bar_mode: 4095,
|
||||
savetime_min: 10,
|
||||
script_commands: true,
|
||||
default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(),
|
||||
pick_add: true,
|
||||
pick_drag_rect: false,
|
||||
@@ -798,6 +803,19 @@ mod tests {
|
||||
assert_eq!(cfg.settings.savetime_min, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_commands_default_on_and_survive_a_round_trip() {
|
||||
let missing: crate::app::config::AppConfig =
|
||||
serde_json::from_str(r#"{"settings": {"pick_add": false}}"#).unwrap();
|
||||
assert!(missing.settings.script_commands, "an old config keeps scripts allowed");
|
||||
let off: crate::app::config::AppConfig =
|
||||
serde_json::from_str(r#"{"settings": {"script_commands": false}}"#).unwrap();
|
||||
assert!(!off.settings.script_commands);
|
||||
let text = serde_json::to_string(&off).unwrap();
|
||||
let back: crate::app::config::AppConfig = serde_json::from_str(&text).unwrap();
|
||||
assert!(!back.settings.script_commands);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isometric_drafting_never_persists_or_loads() {
|
||||
// A persisted On used to bring the isoplane crosshair (vertical +
|
||||
|
||||
@@ -271,7 +271,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
fn style_exists(&self, kind: StyleKind, name: &str) -> bool {
|
||||
pub(super) fn style_exists(&self, kind: StyleKind, name: &str) -> bool {
|
||||
self.style_names(kind)
|
||||
.iter()
|
||||
.any(|n| n.eq_ignore_ascii_case(name))
|
||||
@@ -308,7 +308,7 @@ impl OpenCADStudio {
|
||||
|
||||
/// Refresh anything that mirrors the style list / current style after a
|
||||
/// mutation (ribbon dropdowns, geometry that depends on the style).
|
||||
fn after_style_change(&mut self, kind: StyleKind) {
|
||||
pub(super) fn after_style_change(&mut self, kind: StyleKind) {
|
||||
if kind.in_ribbon() {
|
||||
self.sync_ribbon_styles();
|
||||
}
|
||||
@@ -405,7 +405,7 @@ impl OpenCADStudio {
|
||||
false
|
||||
}
|
||||
|
||||
fn remove_style_storage(&mut self, kind: StyleKind, name: &str) -> bool {
|
||||
pub(super) fn remove_style_storage(&mut self, kind: StyleKind, name: &str) -> bool {
|
||||
let doc = &mut self.tabs[self.active_tab].scene.document;
|
||||
match kind {
|
||||
StyleKind::Text => doc.text_styles.remove(name).is_some(),
|
||||
@@ -541,7 +541,7 @@ impl OpenCADStudio {
|
||||
|
||||
/// Rename `old`→`new` in the backing store, re-keying table entries and
|
||||
/// rewriting name-based references + current-style pointers.
|
||||
fn rename_style_storage(&mut self, kind: StyleKind, old: &str, new: &str) {
|
||||
pub(super) fn rename_style_storage(&mut self, kind: StyleKind, old: &str, new: &str) {
|
||||
let i = self.active_tab;
|
||||
match kind {
|
||||
StyleKind::Text => {
|
||||
|
||||
@@ -619,6 +619,7 @@ impl OpenCADStudio {
|
||||
constraint_bar_display: self.constraint_bar_display,
|
||||
constraint_bar_mode: self.constraint_bar_mode,
|
||||
savetime_min: self.savetime_min,
|
||||
script_commands: self.script_commands,
|
||||
default_save_format: self.default_save_format.clone(),
|
||||
pick_add: self.pick_add,
|
||||
pick_drag_rect: self.pick_drag_rect,
|
||||
@@ -723,6 +724,7 @@ impl OpenCADStudio {
|
||||
self.constraint_bar_display = s.constraint_bar_display.clamp(0, 3);
|
||||
self.constraint_bar_mode = s.constraint_bar_mode.clamp(0, 4095);
|
||||
self.savetime_min = s.savetime_min;
|
||||
self.script_commands = s.script_commands;
|
||||
self.default_save_format =
|
||||
crate::io::canonical_save_format(&s.default_save_format).to_string();
|
||||
self.pick_add = s.pick_add;
|
||||
|
||||
@@ -284,6 +284,24 @@ impl OpenCADStudio {
|
||||
crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles);
|
||||
}
|
||||
|
||||
/// Refresh the shared V4 document after built-in geometry edits. Plugin
|
||||
/// writes publish through HostSession immediately; the fingerprint avoids
|
||||
/// sending a duplicate notification at this message boundary.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn notify_plugins_document_changed(&mut self) {
|
||||
if self.active_tab >= self.tabs.len() {
|
||||
return;
|
||||
}
|
||||
let tab = &self.tabs[self.active_tab];
|
||||
let key = (tab.id, tab.scene.geometry_epoch);
|
||||
if self.last_plugin_document == Some(key) {
|
||||
return;
|
||||
}
|
||||
self.last_plugin_document = Some(key);
|
||||
crate::plugin::v4_support::publish_drawing_changed(tab.id, tab.scene.geometry_epoch);
|
||||
crate::plugin::v4_support::publish_document_view_v4(tab.id, &tab.scene.document);
|
||||
}
|
||||
|
||||
pub fn update(&mut self, msg: Message) -> Task<Message> {
|
||||
if let Some(tab) = self.tabs.get(self.active_tab) {
|
||||
crate::entities::common::set_unit_context(
|
||||
@@ -355,7 +373,29 @@ impl OpenCADStudio {
|
||||
return task;
|
||||
}
|
||||
}
|
||||
// A command's name is `&'static str`, so this runs on every message —
|
||||
// a mouse move included — without allocating.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let active_command = |app: &Self| -> Option<(u64, Option<&'static str>)> {
|
||||
app.tabs
|
||||
.get(app.active_tab)
|
||||
.map(|tab| (tab.id, tab.active_cmd.as_ref().map(|command| command.name())))
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let command_before = active_command(self);
|
||||
let task = self.update_inner(msg);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let command_after = active_command(self);
|
||||
if command_before != command_after {
|
||||
if let Some((tab_id, command)) = command_after {
|
||||
crate::plugin::v4_support::publish_command_state_changed(
|
||||
tab_id,
|
||||
command.map(str::to_owned),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.refresh_gpu_status();
|
||||
self.show_next_startup_modal();
|
||||
self.sync_open_command_history();
|
||||
@@ -389,6 +429,8 @@ impl OpenCADStudio {
|
||||
// and plugin request draining).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.notify_plugins_selection_changed();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.notify_plugins_document_changed();
|
||||
// OTRACK acquires tracking points only while a command or grip drag is
|
||||
// running; drop them once neither is active so the temporary tracking
|
||||
// points / vectors disappear when the command ends (issue #64).
|
||||
|
||||
@@ -25,6 +25,14 @@ pub(crate) fn set_dimension_text_override(base: &mut DimensionBase, text: Option
|
||||
base.user_text = text;
|
||||
}
|
||||
|
||||
/// Recompute the fields a script cannot set: the base definition point and
|
||||
/// the stored measurement, exactly as grip edits do.
|
||||
pub(crate) fn normalize_scripted_dimension(dim: &mut Dimension) {
|
||||
let definition_point = dimension_definition_point(dim);
|
||||
dim.base_mut().definition_point = definition_point;
|
||||
dim.base_mut().actual_measurement = dim.measurement();
|
||||
}
|
||||
|
||||
fn dimension_definition_point(dim: &Dimension) -> acadrust::types::Vector3 {
|
||||
match dim {
|
||||
Dimension::Aligned(d) => d.definition_point,
|
||||
|
||||
@@ -602,3 +602,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the derived spline after a scripted create or edit of the
|
||||
/// generating parameters. `radius` is the top radius; the base radius is the
|
||||
/// start point's distance from the axis.
|
||||
pub(crate) fn normalize_scripted_helix(old: Option<&Helix>, new: &mut Helix) -> Result<(), String> {
|
||||
let unchanged = old.is_some_and(|old| {
|
||||
old.axis_base_point == new.axis_base_point
|
||||
&& old.start_point == new.start_point
|
||||
&& old.axis_vector == new.axis_vector
|
||||
&& old.radius.to_bits() == new.radius.to_bits()
|
||||
&& old.turns.to_bits() == new.turns.to_bits()
|
||||
&& old.turn_height.to_bits() == new.turn_height.to_bits()
|
||||
&& old.handedness == new.handedness
|
||||
&& old.constraint == new.constraint
|
||||
});
|
||||
if unchanged {
|
||||
return Ok(());
|
||||
}
|
||||
let top = new.radius;
|
||||
if rebuild(new, top) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Helix parameters do not define a valid curve".into())
|
||||
}
|
||||
}
|
||||
@@ -418,6 +418,54 @@ fn restore_shifted_mline_segment(
|
||||
}
|
||||
}
|
||||
|
||||
/// Recompute the derived MLine state after a scripted create or edit:
|
||||
/// vertex direction/miter, element segment layout and the per-element miter
|
||||
/// offsets. Dash/cut parameters survive an edit unless a vertex moved.
|
||||
pub(crate) fn normalize_scripted_mline(
|
||||
old: Option<&MLine>,
|
||||
new: &mut MLine,
|
||||
document: &acadrust::CadDocument,
|
||||
) -> Result<(), String> {
|
||||
let style = resolved_mline_style(new, document)
|
||||
.ok_or_else(|| format!("MLine style {:?} does not exist", new.style_name))?;
|
||||
let unchanged = old.is_some_and(|old| {
|
||||
old.vertices == new.vertices
|
||||
&& old.normal == new.normal
|
||||
&& old.flags == new.flags
|
||||
&& old.justification == new.justification
|
||||
&& old.scale_factor.to_bits() == new.scale_factor.to_bits()
|
||||
&& old.style_name == new.style_name
|
||||
&& old.style_handle == new.style_handle
|
||||
});
|
||||
if unchanged {
|
||||
return Ok(());
|
||||
}
|
||||
let moved = old.is_none_or(|old| {
|
||||
old.vertices.len() != new.vertices.len()
|
||||
|| old
|
||||
.vertices
|
||||
.iter()
|
||||
.zip(&new.vertices)
|
||||
.any(|(a, b)| a.position != b.position)
|
||||
});
|
||||
let elements = style.elements.len().max(1);
|
||||
for vertex in &mut new.vertices {
|
||||
if vertex.segments.is_empty() {
|
||||
vertex.init_segments(elements);
|
||||
} else if moved {
|
||||
for segment in &mut vertex.segments {
|
||||
segment.parameters.truncate(1);
|
||||
segment.area_fill_parameters.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
if !rebuild_mline_geometry(new) {
|
||||
return Err("MLine vertices are degenerate".into());
|
||||
}
|
||||
crate::modules::draw::draw::mline::sync_mline_element_parameters(new, style);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a multiline into styled parallel lines in WCS.
|
||||
pub fn resolved_mline_style<'a>(
|
||||
m: &MLine,
|
||||
|
||||
@@ -3339,3 +3339,29 @@ impl Transformable for Table {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the per-cell merge dimensions (what DXF stores) in step with
|
||||
/// `merged_ranges` (what DWG stores) after a scripted create or edit. Cells a
|
||||
/// script did not touch keep their merge state when the ranges are unchanged.
|
||||
pub(crate) fn normalize_scripted_table(old: Option<&Table>, new: &mut Table) {
|
||||
if old.is_some_and(|old| old.merged_ranges == new.merged_ranges) {
|
||||
return;
|
||||
}
|
||||
for row in &mut new.rows {
|
||||
for cell in &mut row.cells {
|
||||
cell.merge_width = 1;
|
||||
cell.merge_height = 1;
|
||||
}
|
||||
}
|
||||
let ranges = new.merged_ranges.clone();
|
||||
for range in &ranges {
|
||||
if let Some(cell) = new
|
||||
.rows
|
||||
.get_mut(range.top_row)
|
||||
.and_then(|row| row.cells.get_mut(range.left_col))
|
||||
{
|
||||
cell.merge_width = range.col_count() as i32;
|
||||
cell.merge_height = range.row_count() as i32;
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
-12
@@ -15,7 +15,8 @@
|
||||
|
||||
#![cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// One entry in the curated plugin registry (`plugins/registry.json`).
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -68,6 +69,9 @@ pub struct ExternalPlugin {
|
||||
pub command_prefixes: Vec<String>,
|
||||
/// The package directory under the plugins folder.
|
||||
pub dir: PathBuf,
|
||||
/// True when the package ships inside the application rather than the
|
||||
/// per-user plugin directory.
|
||||
pub bundled: bool,
|
||||
/// Whether a native library for this platform sits beside `plugin.toml`.
|
||||
pub lib_present: bool,
|
||||
}
|
||||
@@ -155,6 +159,24 @@ pub fn plugins_dir() -> Option<PathBuf> {
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Read-only plugins shipped with the application. The environment override
|
||||
/// makes development and packaging tests independent of a particular bundle
|
||||
/// layout.
|
||||
pub fn bundled_plugins_dir() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("OCS_BUNDLED_PLUGINS_DIR") {
|
||||
return Some(PathBuf::from(p));
|
||||
}
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return Some(exe.parent()?.parent()?.join("Resources").join("plugins"));
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
Some(exe.parent()?.join("plugins"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an installed package's folder. It stays loaded for the current
|
||||
/// session (the library is resident); the removal takes effect on next start.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -179,16 +201,33 @@ fn lib_extension() -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover every package under the plugins directory, sorted by `ribbon_order`
|
||||
/// then id. Missing directory → empty list (not an error).
|
||||
/// Discover bundled and per-user packages, sorted by `ribbon_order` then id.
|
||||
/// A per-user package with the same id explicitly overrides the bundled copy.
|
||||
/// Missing directories are ignored.
|
||||
pub fn discover() -> Vec<ExternalPlugin> {
|
||||
let Some(root) = plugins_dir() else {
|
||||
return Vec::new();
|
||||
discover_from_roots(bundled_plugins_dir().as_deref(), plugins_dir().as_deref())
|
||||
}
|
||||
|
||||
fn discover_from_roots(
|
||||
bundled_root: Option<&Path>,
|
||||
user_root: Option<&Path>,
|
||||
) -> Vec<ExternalPlugin> {
|
||||
let mut found = BTreeMap::new();
|
||||
if let Some(root) = bundled_root {
|
||||
discover_root(root, true, &mut found);
|
||||
}
|
||||
if let Some(root) = user_root {
|
||||
discover_root(root, false, &mut found);
|
||||
}
|
||||
let mut found: Vec<_> = found.into_values().collect();
|
||||
found.sort_by(|a, b| a.ribbon_order.cmp(&b.ribbon_order).then(a.id.cmp(&b.id)));
|
||||
found
|
||||
}
|
||||
|
||||
fn discover_root(root: &Path, bundled: bool, found: &mut BTreeMap<String, ExternalPlugin>) {
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(&root) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut found = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let dir = entry.path();
|
||||
if !dir.is_dir() {
|
||||
@@ -206,11 +245,10 @@ pub fn discover() -> Vec<ExternalPlugin> {
|
||||
}
|
||||
p.lib_present = lib_present_in(&dir);
|
||||
p.dir = dir;
|
||||
found.push(p);
|
||||
p.bundled = bundled;
|
||||
found.insert(p.id.clone(), p);
|
||||
}
|
||||
}
|
||||
found.sort_by(|a, b| a.ribbon_order.cmp(&b.ribbon_order).then(a.id.cmp(&b.id)));
|
||||
found
|
||||
}
|
||||
|
||||
/// True when a file with this platform's dynamic-library extension exists in
|
||||
@@ -298,6 +336,7 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
|
||||
ribbon_order,
|
||||
command_prefixes,
|
||||
dir: PathBuf::new(),
|
||||
bundled: false,
|
||||
lib_present: false,
|
||||
})
|
||||
}
|
||||
@@ -531,6 +570,42 @@ xdata_apps = ["MYPLUGIN_RECORD"]
|
||||
assert!(parse_plugin_toml("name = \"x\"").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_bundled_plugins_and_allows_a_user_override() {
|
||||
let base = std::env::temp_dir().join(format!(
|
||||
"ocs-bundled-plugin-discovery-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let bundled = base.join("bundled").join("opencad.python");
|
||||
let user = base.join("user").join("opencad.python");
|
||||
std::fs::create_dir_all(&bundled).unwrap();
|
||||
std::fs::create_dir_all(&user).unwrap();
|
||||
let manifest = |version: &str| {
|
||||
format!(
|
||||
"[plugin]\nid=\"opencad.python\"\nversion=\"{version}\"\n[opencad]\napi_version=7\n"
|
||||
)
|
||||
};
|
||||
std::fs::write(bundled.join("plugin.toml"), manifest("bundled")).unwrap();
|
||||
std::fs::write(user.join("plugin.toml"), manifest("user")).unwrap();
|
||||
std::fs::write(
|
||||
bundled.join(format!("plugin.{}", lib_extension())),
|
||||
b"fixture",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(user.join(format!("plugin.{}", lib_extension())), b"fixture").unwrap();
|
||||
|
||||
let bundled_only = discover_from_roots(Some(&base.join("bundled")), None);
|
||||
assert_eq!(bundled_only.len(), 1);
|
||||
assert!(bundled_only[0].bundled);
|
||||
assert_eq!(bundled_only[0].version, "bundled");
|
||||
|
||||
let overridden = discover_from_roots(Some(&base.join("bundled")), Some(&base.join("user")));
|
||||
assert_eq!(overridden.len(), 1);
|
||||
assert!(!overridden[0].bundled);
|
||||
assert_eq!(overridden[0].version, "user");
|
||||
std::fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incompatible_api_flagged() {
|
||||
let p = parse_plugin_toml("id=\"a\"\napi_version = 9999").unwrap();
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
// Open CAD Studio plugin runtime. Plugins are external cdylibs loaded from the
|
||||
// user plugins folder (see `external`) and installed via the marketplace; the
|
||||
// host ships no built-in add-ons. See `docs/plugin-architecture.md`.
|
||||
// Open CAD Studio plugin runtime. Plugins are out-of-process cdylibs loaded
|
||||
// from the application bundle or user plugins folder (see `external`).
|
||||
// See `docs/plugin-architecture.md`.
|
||||
|
||||
pub mod external;
|
||||
pub mod host;
|
||||
@@ -81,4 +81,4 @@ mod tests {
|
||||
"expected an error queued for the command line, got: {queued:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,16 @@ pub fn publish_selection_changed_v4(tab_id: u64, handles: Vec<acadrust::Handle>)
|
||||
broadcast(HostNotification::SelectionChangedV4 { tab_id, handles });
|
||||
}
|
||||
|
||||
/// Broadcast a change in a tab's active interactive command.
|
||||
pub fn publish_command_state_changed(tab_id: u64, command: Option<String>) {
|
||||
broadcast(HostNotification::CommandStateChanged { tab_id, command });
|
||||
}
|
||||
|
||||
/// Broadcast drawing changes independently of shared-view subscriptions.
|
||||
pub fn publish_drawing_changed(tab_id: u64, epoch: u64) {
|
||||
broadcast(HostNotification::DrawingChanged { tab_id, epoch });
|
||||
}
|
||||
|
||||
/// V4 notification handler installed on the plugin manager.
|
||||
///
|
||||
/// Forwards REPL status messages to the log; other notifications are handled
|
||||
|
||||
@@ -543,6 +543,9 @@ impl Scene {
|
||||
let Some(existing) = self.document.get_entity(handle) else {
|
||||
return false;
|
||||
};
|
||||
// IPC snapshots omit storage-only payloads (notably Unknown/Extended
|
||||
// entities). Preserve them when replacing a clone from a plugin.
|
||||
entity.preserve_storage_data_from(existing);
|
||||
// The caller edited a snapshot copy; keep the live entity in its block.
|
||||
entity.common_mut().owner_handle = existing.common().owner_handle;
|
||||
|
||||
|
||||
@@ -158,6 +158,16 @@ impl Scene {
|
||||
self.bump_selection_set();
|
||||
}
|
||||
|
||||
/// Script-facing replacement: preserve exactly the validated input order.
|
||||
/// UI picks still use `select_entity` and its leader/annotation expansion.
|
||||
pub(crate) fn replace_selection_exact(&mut self, handles: &[Handle]) {
|
||||
if self.selected_handles_in_order() == handles { return; }
|
||||
self.selected_constraint = None;
|
||||
self.selected = handles.iter().copied().collect();
|
||||
self.selected_order = handles.to_vec();
|
||||
self.bump_selection_set();
|
||||
}
|
||||
|
||||
pub fn select_all_visible(&mut self) -> usize {
|
||||
let block = self.interaction_block_handle();
|
||||
let frozen: Option<HashSet<Handle>> = self
|
||||
|
||||
@@ -309,11 +309,19 @@ fn external_card<'a>(
|
||||
actions = actions.push(toggle_button(&p.id, disabled));
|
||||
actions = actions.push(Space::new().width(6));
|
||||
}
|
||||
actions = actions.push(pill_button(
|
||||
t!("Uninstall"),
|
||||
Message::PluginUninstall(p.id.clone()),
|
||||
button::danger,
|
||||
));
|
||||
if p.bundled {
|
||||
actions = actions.push(
|
||||
text(t!("Bundled with Open CAD Studio"))
|
||||
.size(11)
|
||||
.style(muted_style),
|
||||
);
|
||||
} else {
|
||||
actions = actions.push(pill_button(
|
||||
t!("Uninstall"),
|
||||
Message::PluginUninstall(p.id.clone()),
|
||||
button::danger,
|
||||
));
|
||||
}
|
||||
|
||||
container(column![info, actions].spacing(8).padding([10, 12]))
|
||||
.width(Fill)
|
||||
|
||||
Reference in new issue
Block a user