1use crate::store::{ModelStore, PLUGINS_KEY};
3use brep_render::engine_state::EngineState;
4use eframe::egui;
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Value};
7use std::{cell::RefCell, rc::Rc, sync::mpsc::Receiver};
8
9const MAX_BUNDLE_BYTES: usize = 16 * 1024 * 1024;
10
11#[derive(Clone, Serialize, Deserialize)]
12pub struct InstalledBundle {
13 pub bytes: String,
15 pub enabled: bool,
16 pub id: String,
17 pub digest: String,
18 pub version: String,
19 pub source: Option<String>,
20}
21
22pub type SharedPackages = Rc<RefCell<Vec<InstalledBundle>>>;
23
24pub fn restore(engine: &mut EngineState, packages: &[InstalledBundle]) -> Result<u64, String> {
25 let entries: Result<Vec<_>, String> = packages
26 .iter()
27 .map(|p| {
28 let bundle: brep_plugins::PackageBundle =
29 serde_json::from_str(&p.bytes).map_err(|e| e.to_string())?;
30 let pin = bundle.pin()?;
31 if pin.id != p.id || pin.version != p.version || pin.digest != p.digest {
32 return Err(format!(
33 "Stored plugin {} content does not match its pinned identity",
34 p.id
35 ));
36 }
37 Ok(json!({"bundle": bundle, "enabled": p.enabled}))
38 })
39 .collect();
40 engine.submit_plugin_installations(Value::Array(entries?))
41}
42
43pub fn parse_bundle(bytes: &[u8]) -> Result<(String, Value), String> {
45 if bytes.len() > MAX_BUNDLE_BYTES {
46 return Err("Plugin bundle exceeds 16 MiB".into());
47 }
48 let text = std::str::from_utf8(bytes).map_err(|e| format!("Bundle must be UTF-8: {e}"))?;
49 let value: Value =
50 serde_json::from_str(text).map_err(|e| format!("Invalid plugin bundle JSON: {e}"))?;
51 if !value["manifest"].is_object() || !value["modules"].is_object() {
52 return Err("Expected a bundle containing manifest and modules objects".into());
53 }
54 Ok((text.to_owned(), value))
55}
56
57#[derive(Debug, PartialEq)]
60enum Acquisition {
61 Direct(String),
62 Github {
63 owner: String,
64 repo: String,
65 revision: String,
66 path: String,
67 },
68}
69fn acquisition(url: &str) -> Result<Acquisition, String> {
70 let url = url.trim();
71 if let Some(rest) = url.strip_prefix("https://github.com/") {
72 let parts: Vec<_> = rest.split('/').collect();
73 if parts.len() < 5
74 || parts[2] != "blob"
75 || parts
76 .iter()
77 .any(|p| p.is_empty() || *p == ".." || p.contains(['?', '#', '\\']))
78 {
79 return Err("Use a GitHub blob URL to a .brep-plugin.json bundle (refs containing / require an immutable commit URL)".into());
80 }
81 return Ok(Acquisition::Github {
82 owner: parts[0].into(),
83 repo: parts[1].into(),
84 revision: parts[3].into(),
85 path: parts[4..].join("/"),
86 });
87 }
88 if !(url.starts_with("https://") || url.starts_with("http://")) {
89 return Err("Enter an HTTP(S) bundle URL or GitHub blob URL".into());
90 }
91 Ok(Acquisition::Direct(url.into()))
92}
93
94struct Download {
95 rx: Receiver<Result<Vec<u8>, String>>,
96 source: String,
97 github: Option<(String, String, String)>,
98}
99
100pub struct PluginsPanel {
101 pub open: bool,
102 pub packages: SharedPackages,
103 pub error: Option<String>,
104 pub message: Option<String>,
105 pub(crate) storage_error: Option<String>,
106 url: String,
107 #[cfg(not(target_arch = "wasm32"))]
108 local_path: String,
109 pasted: String,
110 download: Option<Download>,
111 importing: bool,
112 action: Option<ActionForm>,
113 panel_params: std::collections::HashMap<String, Value>,
114 panel_hits: std::collections::HashMap<String, egui::Rect>,
115 ui_package_stamp: String,
116 synced: std::collections::HashMap<u64, String>,
117 pub(crate) active_document: u64,
118 pending_install: Option<(u64, u64, Vec<InstalledBundle>)>,
119 exports: Vec<crate::panels::file::SaveRequest>,
122}
123struct ActionForm {
124 id: String,
125 package_digest: Value,
126 label: String,
127 schema: Value,
128 params: Value,
129 error: Option<String>,
130 pending: bool,
131}
132
133impl PluginsPanel {
134 pub fn new(store: &dyn ModelStore) -> Self {
135 let (packages, error) = match store.read(PLUGINS_KEY) {
136 Some(text) => match serde_json::from_str::<Vec<InstalledBundle>>(&text) {
137 Ok(v) => (v, None),
138 Err(e) => (
139 vec![],
140 Some(format!("Installed plugins could not be read: {e}")),
141 ),
142 },
143 None => (vec![], None),
144 };
145 Self {
146 open: false,
147 packages: Rc::new(RefCell::new(packages)),
148 error,
149 message: None,
150 storage_error: None,
151 url: String::new(),
152 #[cfg(not(target_arch = "wasm32"))]
153 local_path: String::new(),
154 pasted: String::new(),
155 download: None,
156 importing: false,
157 action: None,
158 panel_params: Default::default(),
159 panel_hits: Default::default(),
160 ui_package_stamp: String::new(),
161 synced: Default::default(),
162 active_document: 0,
163 pending_install: None,
164 exports: Vec::new(),
165 }
166 }
167
168 pub fn sync_documents(&mut self, docs: &mut crate::document::Documents) {
169 let packages = self.packages.borrow();
170 let stamp = packages
171 .iter()
172 .map(|p| format!("{}:{}:{}", p.id, p.digest, p.enabled))
173 .collect::<Vec<_>>()
174 .join("|");
175 if self.ui_package_stamp != stamp { self.panel_params.clear(); self.action = None; self.ui_package_stamp = stamp.clone(); }
176 if self.active_document != docs.active_id() { self.panel_params.clear(); self.action = None; }
177 self.active_document = docs.active_id();
178 self.synced
179 .retain(|id, _| docs.iter().any(|d| d.id() == *id));
180 for doc in docs.iter_mut() {
181 if self
182 .pending_install
183 .as_ref()
184 .is_some_and(|p| p.0 == doc.id())
185 {
186 continue;
187 }
188 let status = doc.engine.plugin_installation_status();
189 if status["state"] == "error" {
190 self.error = status["error"].as_str().map(str::to_owned);
191 }
192 if status["state"] == "pending" {
193 continue;
194 }
195 if self.synced.get(&doc.id()) != Some(&stamp) {
196 let expected: Vec<_> = packages
197 .iter()
198 .map(|p| json!([p.id, p.digest, p.enabled]))
199 .collect();
200 let actual: Vec<_> = doc
201 .engine
202 .installed_plugins()
203 .as_array()
204 .into_iter()
205 .flatten()
206 .map(|p| json!([p["id"], p["digest"], p["enabled"]]))
207 .collect();
208 if expected == actual {
209 self.synced.insert(doc.id(), stamp.clone());
210 continue;
211 }
212 match restore(&mut doc.engine, &packages) {
213 Ok(_) => {
214 self.synced.insert(doc.id(), stamp.clone());
215 }
216 Err(e) => {
217 self.error = Some(e);
218 self.synced.insert(doc.id(), stamp.clone());
219 }
220 }
221 }
222 }
223 }
224
225 pub fn close_action(&mut self) {
226 self.action = None;
227 }
228
229 fn persist(&self, store: &dyn ModelStore, next: &[InstalledBundle]) -> Result<(), String> {
230 store.write(
231 PLUGINS_KEY,
232 &serde_json::to_string(next).map_err(|e| e.to_string())?,
233 )
234 }
235
236 pub fn install(
237 &mut self,
238 engine: &mut EngineState,
239 _store: &dyn ModelStore,
240 bytes: &[u8],
241 source: Option<String>,
242 ) -> Result<u64, String> {
243 let (bytes, bundle) = parse_bundle(bytes)?;
244 let typed: brep_plugins::PackageBundle =
245 serde_json::from_value(bundle).map_err(|e| e.to_string())?;
246 let pin = typed.pin()?;
247 let mut next = self.packages.borrow().clone();
248 next.retain(|p| !(p.id == pin.id && p.digest == pin.digest));
249 next.push(InstalledBundle {
250 bytes,
251 enabled: true,
252 id: pin.id,
253 digest: pin.digest,
254 version: pin.version,
255 source,
256 });
257 self.queue_install(engine, next)
258 }
259
260 fn queue_install(
261 &mut self,
262 engine: &mut EngineState,
263 next: Vec<InstalledBundle>,
264 ) -> Result<u64, String> {
265 if self.pending_install.is_some() {
266 return Err("A plugin installation is already pending".into());
267 }
268 let id = restore(engine, &next)?;
269 self.pending_install = Some((self.active_document, id, next));
270 self.error = None;
271 self.storage_error = None;
272 self.message = Some("Validating plugin registrations in the history worker…".into());
273 Ok(id)
274 }
275
276 pub fn finish_installations(
277 &mut self,
278 docs: &mut crate::document::Documents,
279 store: &dyn ModelStore,
280 ) {
281 let Some((owner, id, _)) = &self.pending_install else {
282 return;
283 };
284 let Some(doc) = docs.iter_mut().find(|d| d.id() == *owner) else {
285 self.pending_install = None;
286 self.error = Some("Installation cancelled because its document was closed".into());
287 return;
288 };
289 let status = doc.engine.plugin_installation_status();
290 if status["id"].as_u64() != Some(*id) {
291 return;
292 }
293 match status["state"].as_str() {
294 Some("complete") => {
295 let (_, _, next) = self.pending_install.take().unwrap();
296 match self.persist(store, &next) {
297 Ok(()) => {
298 *self.packages.borrow_mut() = next;
299 self.message = Some(
300 "Installed exact package bytes. Existing document pins stay unchanged."
301 .into(),
302 );
303 }
304 Err(e) => {
305 self.storage_error = Some(e.clone());
306 self.error = Some(e);
307 let _ = restore(&mut doc.engine, &self.packages.borrow());
308 }
309 }
310 }
311 Some("error") => {
312 self.error = Some(
313 status["error"]
314 .as_str()
315 .unwrap_or("Plugin installation failed")
316 .into(),
317 );
318 self.pending_install = None;
319 }
320 _ => {}
321 }
322 }
323
324 pub fn open_action(&mut self, engine: &EngineState, id: &str) -> Result<(), String> {
325 if self.action.as_ref().is_some_and(|a| a.pending) {
326 return Err("Wait for or cancel the pending action before opening another".into());
327 }
328 let catalogue = engine.plugin_catalogue();
329 let action = catalogue["actions"]
330 .as_array()
331 .and_then(|a| a.iter().find(|a| a["id"] == id))
332 .ok_or_else(|| format!("Plugin action {id} is unavailable"))?;
333 let schema = action["inputParamsSchema"].clone();
334 let mut params = json!({});
335 if let Some(fields) = schema.as_object() {
336 for (key, field) in fields {
337 if let Some(default) = field.get("default_value") {
338 params[key] = default.clone();
339 }
340 }
341 }
342 self.action = Some(ActionForm {
343 id: id.into(),
344 package_digest: action["package"]["digest"].clone(),
345 label: action["ribbonPath"].as_str().and_then(|p| p.rsplit('/').next()).or(action["label"].as_str()).unwrap_or(id).into(),
346 schema,
347 params,
348 error: None,
349 pending: false,
350 });
351 Ok(())
352 }
353
354 pub fn poll(&mut self, ctx: &egui::Context, engine: &mut EngineState, store: &dyn ModelStore) {
355 if self.importing {
356 if let Some(file) = store.take_import() {
357 self.importing = false;
358 if let Err(e) = self.install(engine, store, &file.bytes, None) {
359 self.error = Some(e);
360 }
361 }
362 }
363 let result = self.download.as_ref().and_then(|d| d.rx.try_recv().ok());
364 if let Some(result) = result {
365 let download = self.download.take().unwrap();
366 match result {
367 Err(e) => self.error = Some(e),
368 Ok(bytes) => {
369 if let Some((owner, repo, path)) = download.github {
370 let commit = serde_json::from_slice::<Value>(&bytes)
371 .ok()
372 .and_then(|v| v["sha"].as_str().map(str::to_owned));
373 match commit.filter(|sha| {
374 sha.len() == 40 && sha.bytes().all(|c| c.is_ascii_hexdigit())
375 }) {
376 Some(sha) => {
377 let source = format!(
378 "https://raw.githubusercontent.com/{owner}/{repo}/{sha}/{path}"
379 );
380 self.download = Some(Download {
381 rx: crate::http::fetch_bytes(ctx, source.clone()),
382 source,
383 github: None,
384 });
385 }
386 None => {
387 self.error =
388 Some("GitHub did not return an immutable commit SHA".into())
389 }
390 }
391 } else if let Err(e) =
392 self.install(engine, store, &bytes, Some(download.source))
393 {
394 self.error = Some(e);
395 }
396 }
397 }
398 }
399 if let Some(result) = engine.poll_plugin_action() {
400 match result {
401 Ok(value) => {
402 self.message = Some(value.to_string());
403 if let Some(form) = &mut self.action {
404 form.pending = false;
405 }
406 }
407 Err(e) => {
408 self.error = Some(e.clone());
409 if let Some(form) = &mut self.action {
410 form.error = Some(e);
411 form.pending = false;
412 }
413 }
414 }
415 }
416 if self.download.is_some()
417 || self.pending_install.is_some()
418 || engine.plugin_installation_status()["state"] == "pending"
419 || self.action.as_ref().is_some_and(|a| a.pending)
420 {
421 ctx.request_repaint_after(std::time::Duration::from_millis(30));
422 }
423 }
424
425 pub fn take_save_requests(&mut self) -> Vec<crate::panels::file::SaveRequest> {
428 std::mem::take(&mut self.exports)
429 }
430
431 pub fn show(&mut self, ctx: &egui::Context, engine: &mut EngineState, store: &dyn ModelStore) {
432 let mut open = self.open;
433 egui::Window::new("Plugins").open(&mut open).default_width(560.0).default_pos(crate::panels::below_chrome(ctx, 400.0, 0.0)).show(ctx, |ui| {
434 ui.label("Install trusted JavaScript package bundles. Code runs with the CAD host API; installation is separate from opening a document.");
435 if let Some(error) = &self.error { ui.colored_label(egui::Color32::LIGHT_RED, error); }
436 if let Some(message) = &self.message { ui.label(message); }
437 if engine.plugin_installation_status()["state"] == "pending" { ui.spinner(); ui.label("Validating package registrations…"); }
438 for error in engine.plugin_catalogue()["errors"].as_array().into_iter().flatten() { if let Some(error) = error.as_str() { ui.colored_label(egui::Color32::LIGHT_RED, error); } }
439 ui.horizontal(|ui| {
440 ui.label("Package URL");
442 ui.add(egui::TextEdit::singleline(&mut self.url).hint_text("https://… or a github.com repository"));
443 if ui.add_enabled(self.download.is_none(), egui::Button::new("Install URL / GitHub")).clicked() {
444 match acquisition(&self.url) {
445 Ok(Acquisition::Direct(source)) => self.download = Some(Download { rx: crate::http::fetch_bytes(ctx, source.clone()), source, github: None }),
446 Ok(Acquisition::Github { owner, repo, revision, path }) => { let source = format!("https://api.github.com/repos/{owner}/{repo}/commits/{revision}"); self.download = Some(Download { rx: crate::http::fetch_bytes(ctx, source.clone()), source, github: Some((owner, repo, path)) }); },
447 Err(e) => self.error = Some(e),
448 }
449 }
450 });
451 if self.download.is_some() { ui.spinner(); ui.label("Acquiring pinned package bytes…"); }
452 if store.supports_file_interchange() && ui.button("Import local bundle…").clicked() {
453 match store.begin_import_filtered(("Plugin bundle", &["json"])) { Ok(()) => self.importing = true, Err(e) => self.error = Some(e) }
454 }
455 #[cfg(not(target_arch = "wasm32"))]
456 ui.horizontal(|ui| { ui.label("Local bundle path"); ui.text_edit_singleline(&mut self.local_path); if ui.button("Import").clicked() { let result = std::fs::read(&self.local_path).map_err(|e| e.to_string()).and_then(|bytes| self.install(engine, store, &bytes, None)); if let Err(e) = result { self.error = Some(e); } } });
457 ui.collapsing("Paste portable bundle JSON", |ui| { ui.add(egui::TextEdit::multiline(&mut self.pasted).code_editor().desired_rows(6)); if ui.button("Install pasted bundle").clicked() { let bytes = self.pasted.as_bytes().to_vec(); if let Err(e) = self.install(engine, store, &bytes, None) { self.error = Some(e); } } });
458 ui.separator();
459 let installed = self.packages.borrow().clone();
460 for package in &installed {
461 ui.group(|ui| {
462 ui.label(format!("{} {}", package.id, package.version));
463 ui.small(format!("SHA-256 {}", package.digest));
464 if let Some(source) = &package.source { ui.small(source); }
465 ui.horizontal_wrapped(|ui| {
466 let mut enabled = package.enabled;
467 if ui.checkbox(&mut enabled, "Enabled").changed() { self.change(engine, store, package, Some(enabled)); }
468 if ui.button("Use in document").clicked() { if let Err(e) = engine.select_plugin(&package.id, &package.digest) { self.error = Some(e); } }
469 if ui.button("Migrate document").clicked() { if let Err(e) = engine.migrate_plugin(&package.id, &package.digest) { self.error = Some(e); } }
470 if ui.button("Reload stored bytes").clicked() { if let Err(e) = self.queue_install(engine, installed.clone()) { self.error = Some(e); } }
471 if ui.button("Export").clicked() { self.exports.push(crate::panels::file::SaveRequest::text("Export the plugin bundle", format!("{}-{}.brep-plugin.json", package.id.replace('/', "_"), package.version), package.bytes.clone())); }
472 if ui.button("Remove").clicked() { self.change(engine, store, package, None); }
473 });
474 });
475 }
476 ui.small("Updates keep old bundles. Reload revalidates stored bytes; import again to load edited source. Panels appear in their claimed workbench; typed annotations are authored in PMI.");
477 });
478 self.open = open;
479 self.show_action(ctx, engine);
480 }
481
482 pub(crate) fn change(
483 &mut self,
484 engine: &mut EngineState,
485 _store: &dyn ModelStore,
486 package: &InstalledBundle,
487 enabled: Option<bool>,
488 ) {
489 let result = (|| {
490 let mut next = self.packages.borrow().clone();
491 if let Some(enabled) = enabled {
492 for p in &mut next {
493 if p.id == package.id && p.digest == package.digest {
494 p.enabled = enabled;
495 }
496 }
497 } else {
498 next.retain(|p| !(p.id == package.id && p.digest == package.digest));
499 }
500 self.queue_install(engine, next)?;
501 self.action = None;
502 Ok::<_, String>(())
503 })();
504 if let Err(e) = result {
505 self.error = Some(e);
506 }
507 }
508
509 pub fn clear_panel_hits(&mut self) { self.panel_hits.clear(); }
510 pub fn panel_hits_json(&self) -> String { crate::automation::hit_rects::hits_json(&self.panel_hits) }
511
512 pub fn show_panel(&mut self, ui: &mut egui::Ui, engine: &mut EngineState, id: &str) {
514 let catalogue = engine.plugin_catalogue();
515 let Some(panel) = catalogue["panels"].as_array().into_iter().flatten().find(|p| p["id"] == id) else {
516 ui.label("Panel owner is unavailable for this document."); return;
517 };
518 let pending = engine.plugin_action_status()["state"] == "pending";
519 egui::ScrollArea::vertical().id_salt(("plugin-panel", id)).show(ui, |ui| {
520 for (index, control) in panel["controls"].as_array().into_iter().flatten().enumerate() {
521 ui.push_id((id, index), |ui| {
522 match control["type"].as_str().unwrap_or("") {
523 "text" => { ui.label(control["text"].as_str().unwrap_or("")); }
524 "table" => { egui::Grid::new("table").striped(true).show(ui, |ui| {
525 for column in control["columns"].as_array().into_iter().flatten() { ui.strong(column.as_str().unwrap_or("")); } ui.end_row();
526 for row in control["rows"].as_array().into_iter().flatten() {
527 for cell in row.as_array().into_iter().flatten() { ui.label(cell.as_str().unwrap_or("")); } ui.end_row();
528 }
529 }); }
530 "action" | "form" => {
531 let action_id = control["action"].as_str().unwrap_or("");
532 let Some(action) = catalogue["actions"].as_array().into_iter().flatten().find(|a| a["id"] == action_id) else {
533 ui.label("Action owner unavailable"); return;
534 };
535 let key = format!("{}:{}:{}", panel["package"]["digest"], id, control["id"]);
536 let params = self.panel_params.entry(key).or_insert_with(|| {
537 let mut params = schema_defaults(&action["inputParamsSchema"]);
538 if let Some(overrides) = control["params"].as_object() { params.as_object_mut().unwrap().extend(overrides.clone()); } params
539 });
540 ui.add_enabled_ui(!pending && engine.history.locked().is_none(), |ui| {
541 if control["type"] == "form" { schema_fields(ui, engine, &action["inputParamsSchema"], params); }
542 let button = ui.button(control["label"].as_str().unwrap_or(action_id));
543 self.panel_hits.insert(format!("panel:{id}:{}", control["id"].as_str().unwrap_or("")), button.rect);
544 if button.clicked() {
545 let selection = serde_json::from_str(&engine.selection_json()).unwrap_or(Value::Null);
546 match engine.plugin_action(action_id, params.clone(), selection) {
547 Ok(_) => { self.error = None; ui.ctx().request_repaint(); }
548 Err(e) => self.error = Some(e),
549 }
550 }
551 });
552 }
553 _ => { ui.label("Unsupported panel control"); }
554 }
555 });
556 }
557 if pending { ui.spinner(); ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); }
558 if let Some(error) = &self.error { ui.colored_label(egui::Color32::LIGHT_RED, error); }
559 });
560 }
561
562 fn show_action(&mut self, ctx: &egui::Context, engine: &mut EngineState) {
563 let Some(form) = &mut self.action else { return };
564 if !engine.plugin_catalogue()["actions"].as_array().into_iter().flatten().any(|a| a["id"] == form.id && a["package"]["digest"] == form.package_digest) {
565 self.action = None; return;
566 }
567 let mut open = true;
568 egui::Window::new(&form.label)
569 .id(egui::Id::new("plugin-action-form"))
570 .open(&mut open)
571 .show(ctx, |ui| {
572 ui.small(&form.id);
573 if let Some(error) = &form.error {
574 ui.colored_label(egui::Color32::LIGHT_RED, error);
575 }
576 ui.add_enabled_ui(!form.pending, |ui| {
577 schema_fields(ui, engine, &form.schema, &mut form.params);
578 if ui.button("Run action").clicked() {
579 match engine.plugin_action(
580 &form.id,
581 form.params.clone(),
582 serde_json::from_str(&engine.selection_json()).unwrap_or(Value::Null),
583 ) {
584 Ok(_) => {
585 form.pending = true;
586 form.error = None;
587 }
588 Err(e) => form.error = Some(e),
589 }
590 }
591 });
592 if form.pending {
593 ui.spinner();
594 if ui.button("Cancel").clicked() {
595 engine.cancel_plugin_action();
596 form.pending = false;
597 }
598 }
599 });
600 if !open {
601 if form.pending {
602 engine.cancel_plugin_action();
603 }
604 self.action = None;
605 }
606 }
607}
608
609pub(crate) fn schema_defaults(schema: &Value) -> Value {
610 let mut params = json!({});
611 for (key, field) in schema.as_object().into_iter().flatten() {
612 if let Some(default) = field.get("default_value") { params[key] = default.clone(); }
613 }
614 params
615}
616
617pub(crate) fn schema_fields(ui: &mut egui::Ui, engine: &EngineState, schema: &Value, params: &mut Value) {
618 let entry = json!({"inputParamsSchema": schema});
619 let fields = brep_render::features::plugin_form_fields_from_schema(&entry);
620 let selection: Value = serde_json::from_str(&engine.selection_json()).unwrap_or(Value::Null);
621 for field in &fields {
622 ui.label(&field.label);
623 let mut actions = crate::form::FieldActions::default();
624 crate::form::field_input(ui, field, params, None, &mut actions);
625 if actions.clicked.is_some() {
626 if let brep_render::style::FieldKind::Reference { filter, multiple } = &field.kind {
627 let names: Vec<Value> = filter.iter().flat_map(|kind| {
628 let key = match kind.to_ascii_lowercase().as_str() {
629 "solid" => "solids", "face" => "faces", "edge" => "edges", "datum" => "datums", _ => "",
630 };
631 selection[key].as_array().into_iter().flatten().cloned()
632 }).collect();
633 params[field.key()] = if *multiple { Value::Array(names) } else { names.into_iter().next().unwrap_or(Value::Null) };
634 }
635 }
636 if matches!(&field.kind, brep_render::style::FieldKind::Reference { .. }) { ui.small("Select uses the current viewport selection."); }
637 }
638}
639
640pub fn feature_label(engine: &EngineState, ty: &str) -> String {
641 if !ty.contains('/') {
642 return brep_render::features::feature_plain_name(ty);
643 }
644 engine
645 .feature_schema(ty)
646 .and_then(|s| s["longName"].as_str().map(str::to_owned))
647 .unwrap_or_else(|| ty.to_owned())
648}
649
650
651pub const HIT_KEYS: &[crate::automation::hit_keys::HitKeyDoc] = &[crate::automation::hit_keys::HitKeyDoc { panel: "plugins", prefix: "panel:", meaning: "submit the named action for a declarative panel control", command: Some("plugin_action") }];