use crate::automation::hit_keys::HitKeyDoc;
use crate::form_view::{form_view, FormBanner, FormViewSpec};
use crate::panels::spline_anchors;
use crate::panels::cage_editor;
use crate::palette::{Palette, PaletteItem};
use crate::panels::tree::{self, TreeRow};
use brep_render::engine_state::EngineState;
use brep_render::engine_state::RefSelectTarget;
use brep_render::features;
use eframe::egui;
use serde_json::Value;
use std::collections::HashMap;
const DIALOG_HOVER_OWNER: &str = "history";
const DELETE_RED: egui::Color32 = egui::Color32::from_rgb(0xd8, 0x54, 0x4f);
const ERROR_RED: egui::Color32 = egui::Color32::from_rgb(0xff, 0x6b, 0x6b);
const NOTE_AMBER: egui::Color32 = egui::Color32::from_rgb(0xe8, 0xa3, 0x3d);
const PARTIAL_ORANGE: egui::Color32 = egui::Color32::from_rgb(0xf2, 0x8c, 0x3c);
const APPROX_BLUE: egui::Color32 = egui::Color32::from_rgb(0x5f, 0xb3, 0xe8);
const ROLLBACK_BAR_H: f32 = 8.0;
fn feature_error_message(report: &serde_json::Value, id: &str) -> Option<String> {
let prefix = format!("{id}: ");
report
.get("featureErrors")?
.as_array()?
.iter()
.filter_map(serde_json::Value::as_str)
.find(|entry| entry.starts_with(&prefix))
.map(|entry| entry[prefix.len()..].to_string())
}
fn feature_error_for_user(report: &Value, state: &EngineState, id: &str) -> Option<String> {
let message = feature_error_message(report, id)?;
let index = feature_index_of(state, id)?;
if !state.is_face_transform_feature(index) {
return Some(explain_feature_refusal(&message, &report["featureRefusals"][id]));
}
let params: Value = serde_json::from_str(&state.feature_params_json(index)).ok()?;
let faces = brep_render::face_transform_help::selected_face_names(params.get("faces"));
let facts = brep_render::face_transform_help::RefusalFacts::from_report(report, id);
let refusal = brep_render::face_transform_help::explain_refusal(&message, facts, &faces);
Some(format!(
"{} refused. {} {}",
refusal.motion, refusal.reason, refusal.hint
))
}
fn explain_feature_refusal(message: &str, refusal: &Value) -> String {
let guidance = match refusal.get("class").and_then(Value::as_str) {
Some("invalid_input") =>
"Invalid input. Check the selected references and parameter values.",
Some("unsupported_geometry") =>
"Unsupported geometry. This operation is not yet supported for the selected geometry.",
Some("ill_posed") =>
"Ambiguous operation. Adjust the selection or parameters to specify a unique result.",
Some("non_convergence") =>
"Solver did not converge. Try a smaller change or split the operation into simpler steps.",
Some("tangent_node_singularity") =>
"Tangent intersection. Adjust the geometry near the tangency or split the operation.",
Some("degenerate_arrangement" | "non_integral_genus" | "invalid_result_topology" | "non_positive_volume") =>
"Invalid solid result. The operation could not produce a valid solid; check for collapsed or zero-thickness regions.",
Some("conservative_empty_overlap") =>
"Uncertain overlap. The operation could not resolve touching boundaries; check the contact region.",
Some("unsound_result") =>
"Invalid geometry result. The operation produced intersecting or folded geometry, or an invalid boundary.",
Some("internal") =>
"Internal geometry error. Save the model and report this operation with the details below.",
_ => return message.to_string(),
};
format!("{guidance}\n\n{message}")
}
fn feature_fulfilment_message(report: &serde_json::Value, id: &str) -> Option<String> {
report
.get("featureFulfilment")?
.get(id)?
.get("summary")?
.as_str()
.map(str::to_string)
}
fn feature_approximations(report: &serde_json::Value, id: &str) -> Vec<(String, String)> {
report
.get("featureApproximations")
.and_then(|m| m.get(id))
.and_then(Value::as_array)
.map(|entries| {
entries
.iter()
.map(|entry| {
let message = entry.get("message").and_then(Value::as_str).unwrap_or("");
let summary = entry
.get("summary")
.and_then(Value::as_str)
.unwrap_or(message)
.to_string();
let code = entry.get("code").and_then(Value::as_str).unwrap_or("?");
(summary, format!("{code}: {message}"))
})
.collect()
})
.unwrap_or_default()
}
fn boolean_target_hint(report: &serde_json::Value, id: &str) -> Option<String> {
if let Some(name) = report.get("featureAutoTargets").and_then(|m| m.get(id)).and_then(Value::as_str) {
return Some(format!("Auto: {name}"));
}
let rejected = report.get("featureFulfilment")?.get(id)?.get("rejected")?.as_array()?;
rejected
.iter()
.any(|entry| {
entry.get("name").and_then(Value::as_str) == Some("boolean")
&& entry.get("kind").and_then(Value::as_str) == Some("nothing_selected")
})
.then(|| "pick the target: the part holds several solids".to_string())
}
fn feature_note_message(report: &serde_json::Value, id: &str) -> Option<String> {
let prefix = format!("{id}: ");
let notes: Vec<String> = report
.get("featureNotes")?
.as_array()?
.iter()
.filter_map(serde_json::Value::as_str)
.filter(|entry| entry.starts_with(&prefix))
.map(|entry| entry[prefix.len()..].to_string())
.collect();
(!notes.is_empty()).then(|| notes.join("; "))
}
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub enum PanelMode {
#[default]
Tree,
Form { feature_id: String },
}
impl PanelMode {
fn feature_id(&self) -> Option<&str> {
match self {
PanelMode::Tree => None,
PanelMode::Form { feature_id } => Some(feature_id.as_str()),
}
}
}
#[derive(Default)]
pub struct HistoryPanel {
rejected_edit: Option<(String, String)>,
hits: HashMap<String, egui::Rect>,
mode: PanelMode,
gizmo_armed_for: Option<String>,
drag_src: Option<usize>,
palette: Palette,
palette_display_loaded: bool,
pending_button: Option<(String, String)>,
pending_insert_component: bool,
anchor_selected: Option<usize>,
form_opens: u64,
}
impl HistoryPanel {
pub fn sync_palette_display(&mut self, store: &dyn crate::store::ModelStore) {
use crate::store::FEATURE_PALETTE_DISPLAY_KEY;
if !self.palette_display_loaded {
self.palette.display = store.read(FEATURE_PALETTE_DISPLAY_KEY)
.and_then(|json| serde_json::from_str(&json).ok()).unwrap_or_default();
self.palette_display_loaded = true;
}
if self.palette.take_display_change() {
if let Ok(json) = serde_json::to_string(&self.palette.display) {
let _ = store.write(FEATURE_PALETTE_DISPLAY_KEY, &json);
}
}
}
pub fn new() -> Self {
Self::default()
}
pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
self.hits.clear();
self.hits.insert("panel:clip".into(), ui.clip_rect());
if let Some(id) = self.mode.feature_id() {
if feature_index_of(state, id).is_none() {
self.mode = PanelMode::Tree;
}
}
let report: Value = serde_json::from_str(&state.history_report_json()).unwrap_or(Value::Null);
ui.spacing_mut().item_spacing.y = 2.0;
match self.mode.clone() {
PanelMode::Tree => {
state.refresh_spline_edit_overlay(None, None);
state.refresh_cage_edit_overlay(None);
self.show_tree(ui, state, &report);
}
PanelMode::Form { feature_id } => self.show_form(ui, state, &report, &feature_id),
}
if let Some((fid, key)) = self.pending_button.take() {
self.handle_feature_button(state, &fid, &key);
}
let open_feature = self.mode.feature_id().map(str::to_string);
if self.gizmo_armed_for != open_feature {
self.anchor_selected = None;
state.disarm_transform();
if let Some(id) = open_feature.clone() {
if state.feature_dimension_annotations_json(&id) != "[]" {
state.arm_dimension(&id);
} else if state.feature_has_transform(&id) {
state.arm_transform(&id);
} else if state.is_spline_feature(&id) {
let first_free = state
.spline_anchors(&id)
.iter()
.find(|row| row.attached.is_none())
.map(|row| row.index);
if let Some(index) = first_free {
if state.arm_spline_anchor(&id, index) {
self.anchor_selected = Some(index);
}
}
}
}
self.gizmo_armed_for = open_feature;
} else if let Some(id) = open_feature.as_deref() {
if !state.transform_armed()
&& feature_index_of(state, id).is_some_and(|index| state.is_face_transform_feature(index))
&& state.face_transform_has_pivot(id)
{
state.arm_transform(id);
}
}
}
fn show_form(
&mut self,
ui: &mut egui::Ui,
state: &mut EngineState,
report: &Value,
id: &str,
) {
let Some(index) = feature_index_of(state, id) else {
return;
};
let ty = state.feature_type_at(index).unwrap_or_else(|| "?".into());
let title = format!("{id} {}", crate::plugins::feature_label(state, &ty));
let mut fields = state.feature_form_fields(&ty);
if let Some(hint) = boolean_target_hint(report, id) {
for field in fields.iter_mut().filter(|f| f.path == ["boolean", "targets"]) {
field.label = format!("{} ({hint})", field.label);
}
}
let mut params: Value =
serde_json::from_str(&state.feature_params_json(index)).unwrap_or(Value::Null);
let hidden = features::feature_hidden_params(&ty, ¶ms);
let error = feature_error_for_user(report, state, id);
let partial = feature_fulfilment_message(report, id);
let note = feature_note_message(report, id);
let approximations = feature_approximations(report, id);
let approximation_summaries = approximations
.iter()
.map(|(summary, _)| summary.as_str())
.collect::<Vec<_>>()
.join("\n");
let rejected = self.rejected_edit.as_ref().filter(|(feature, _)| feature == id).map(|(_, why)| why.as_str());
let error = match (rejected, error) {
(Some(rejected), Some(error)) => Some(format!("{rejected}\n{error}")),
(Some(rejected), None) => Some(rejected.to_string()),
(None, error) => error,
};
let banner = error
.as_deref()
.map(|message| FormBanner::error(message, ERROR_RED))
.or_else(|| partial.as_deref().map(|message| FormBanner::note(message, PARTIAL_ORANGE)))
.or_else(|| note.as_deref().map(|message| FormBanner::note(message, NOTE_AMBER)))
.or_else(|| {
(!approximation_summaries.is_empty())
.then_some(FormBanner::note(approximation_summaries.as_str(), APPROX_BLUE))
});
let outputs: Vec<String> = report
.get("featureOutputs")
.and_then(|m| m.get(id))
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let mut trailing = vec![("Outputs", outputs)];
if !approximations.is_empty() {
trailing.push((
"Approximations",
approximations.iter().map(|(_, text)| text.clone()).collect(),
));
}
let is_spline = ty == "SP";
if is_spline {
if let Some(index) = state.take_spline_anchor_pick() {
self.anchor_selected = Some(index);
}
}
let anchor_rows = if is_spline { state.spline_anchors(id) } else { Vec::new() };
let anchor_intents: std::cell::RefCell<Vec<spline_anchors::AnchorIntent>> =
std::cell::RefCell::new(Vec::new());
let anchor_hits: std::cell::RefCell<Vec<(String, egui::Rect)>> =
std::cell::RefCell::new(Vec::new());
let anchor_selected = self.anchor_selected;
let draw_anchors = |ui: &mut egui::Ui| {
spline_anchors::draw(ui, &anchor_rows, anchor_selected, &anchor_intents, &anchor_hits);
};
let is_cage = ty == "NS";
let cage_summary = if is_cage { state.cage_summary(id) } else { None };
let cage_points = if is_cage { state.cage_points(id) } else { Vec::new() };
let cage_selected = if is_cage && state.cage_armed_for(id) { state.cage_selected() } else { Vec::new() };
let cage_intents: std::cell::RefCell<Vec<cage_editor::CageIntent>> =
std::cell::RefCell::new(Vec::new());
let cage_hits: std::cell::RefCell<Vec<(String, egui::Rect)>> =
std::cell::RefCell::new(Vec::new());
let draw_cage = |ui: &mut egui::Ui| {
cage_editor::draw(ui, cage_summary.as_ref(), &cage_points, &cage_selected, &cage_intents, &cage_hits);
};
let picking = state
.ref_select_field()
.filter(|f| f.target == RefSelectTarget::Feature && f.subject == id);
let spec = FormViewSpec {
title: &title,
subtitle: None,
fields: &fields,
hidden: Some(&hidden),
banner,
trailing: Some(trailing.as_slice()),
exit_label: "Return to tree",
extra: if is_spline {
Some(("Anchors", &draw_anchors as &dyn Fn(&mut egui::Ui)))
} else if is_cage {
Some(("Cage", &draw_cage as &dyn Fn(&mut egui::Ui)))
} else {
None
},
picking: picking.as_ref().map(|f| crate::form::ActivePick { path: &f.path, names: &f.names }),
rollback: true,
hits_prefix: "",
read_only: state.history.locked(),
};
let out = form_view(ui, &spec, &mut params, Some(&mut self.hits));
for (key, rect) in anchor_hits.into_inner() {
self.hits.insert(key, rect);
}
for (key, rect) in cage_hits.into_inner() {
self.hits.insert(key, rect);
}
if is_spline {
if let Some(intent) = anchor_intents.into_inner().into_iter().next() {
self.anchor_selected = spline_anchors::apply(state, id, intent, self.anchor_selected);
}
state.refresh_spline_edit_overlay(Some(id), self.anchor_selected);
} else {
state.refresh_spline_edit_overlay(None, None);
}
if is_cage {
if let Some(intent) = cage_intents.into_inner().into_iter().next() {
cage_editor::apply(state, id, intent);
}
state.refresh_cage_edit_overlay(Some(id));
} else {
state.refresh_cage_edit_overlay(None);
}
let anchor = self
.hits
.get("form:feature")
.map(|r| r.min)
.unwrap_or(egui::Pos2::ZERO);
self.hits.insert(
format!("form:feature:{id}"),
egui::Rect::from_min_size(anchor, egui::Vec2::ZERO),
);
if let Some(index) = out.pick_remove {
state.ref_select_remove(index);
}
if out.pick_done {
state.finish_ref_select();
}
if out.changed {
self.rejected_edit = state.update_feature_params(id, ¶ms.to_string()).err().map(|e| (id.to_string(), e));
ui.ctx().request_repaint();
if picking.is_some() {
state.ref_select_request_finish();
}
}
if let Some(key) = out.button_clicked {
if picking.is_some() {
state.finish_ref_select();
}
self.pending_button = Some((id.to_string(), key));
}
if let Some(activate) = out.ref_activate {
if state.ref_select_active() {
state.finish_ref_select();
}
state.begin_ref_select(
id,
activate.path,
activate.label,
activate.filter,
activate.multiple,
activate.seed,
);
}
let hover_changed = match &out.hovered_entity {
Some(name) => state.hover_entity_by_name(DIALOG_HOVER_OWNER, name),
None => state.dialog_hover_end(DIALOG_HOVER_OWNER),
};
if hover_changed {
ui.ctx().request_repaint();
}
if out.exit_clicked {
if state.ref_select_field().is_some_and(|f| f.target == RefSelectTarget::Feature) {
state.finish_ref_select();
}
self.mode = PanelMode::Tree;
}
if state
.ref_select_field()
.is_some_and(|f| f.target == RefSelectTarget::Feature && f.subject == id)
{
state.ref_select_claim();
}
if out.roll_to_tip {
state.roll_to(state.history_len().saturating_sub(1));
}
}
fn show_tree(&mut self, ui: &mut egui::Ui, state: &mut EngineState, report: &Value) {
let cancelled = state.cancelled_run().map(str::to_string);
tree::node(
ui,
TreeRow {
guides: &[],
is_last: true,
expandable: true,
expanded: true,
root: true,
glyph: None,
label: "Features",
selected: false,
highlighted: false,
draggable: false,
tint: None,
},
|ui| {
if let Some(cancelled) = &cancelled {
let note = if cancelled.is_empty() {
"run cancelled — the next edit rebuilds".to_string()
} else {
format!("run cancelled at {cancelled} — edit or delete it to rebuild")
};
ui.colored_label(ERROR_RED, note);
}
},
);
let len = state.history_len();
if len == 0 {
let g = tree::child_guides(&[], true);
tree::node(ui, TreeRow::leaf(&g, true, "(empty — add a feature)"), |_| {});
}
let mut roll: Option<usize> = None;
let mut delete: Option<String> = None;
let mut drag_move: Option<(usize, usize)> = None;
let mut select: Option<String> = None;
let mut open_form: Option<(usize, String)> = None;
let mut feature_rects: Vec<(usize, egui::Rect)> = Vec::with_capacity(len);
let current = state.history_rollback();
let selected_feature = state.selected_feature().map(str::to_string);
let user_errors: std::collections::HashMap<String, String> = (0..len)
.filter_map(|i| {
let id = state.feature_id_at(i)?;
let message = feature_error_for_user(report, state, &id)?;
Some((id, message))
})
.collect();
for i in 0..len {
let ty = state.feature_type_at(i).unwrap_or_else(|| "?".into());
let id = state.feature_id_at(i).unwrap_or_else(|| "(no id)".into());
let is_last_feature = i + 1 == len;
let ms = report
.get("featureTimings")
.and_then(|m| m.get(&id))
.and_then(Value::as_f64)
.unwrap_or(0.0);
let glyph = features::feature_icon(&ty).map(String::from);
let label = format!("{id} {}", crate::plugins::feature_label(state, &ty));
let pending = i > current;
ui.scope(|ui| {
if pending {
ui.set_opacity(ui.visuals().disabled_alpha());
}
let mut del_rect = egui::Rect::NOTHING;
let mut del_clicked = false;
let mut edit_rect = egui::Rect::NOTHING;
let mut edit_clicked = false;
let resp = tree::node(
ui,
TreeRow::branch(&[], is_last_feature, !pending, &label)
.glyph(glyph.as_deref())
.selected(selected_feature.as_deref() == Some(id.as_str()))
.draggable(true),
|ui| {
let del = ui.add(
crate::icon_text::icon_button_colored(ui, "✕", Some(DELETE_RED))
.stroke(egui::Stroke::new(1.0, DELETE_RED))
.small()
.min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)),
);
del_rect = del.rect;
del_clicked = del.clicked();
ui.add_space(4.0);
let edit = ui
.add(crate::icon_text::icon_button(ui, "✎").small()
.min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)))
.on_hover_text("Edit this feature");
edit_rect = edit.rect;
edit_clicked = edit.clicked();
ui.add_space(6.0);
ui.add(
egui::Label::new(
egui::RichText::new(format!("{} ms", ms.round() as i64)).weak(),
)
.selectable(false),
);
},
);
self.hits.insert(format!("step:{i}"), resp.band.rect);
self.hits.insert(format!("box:{i}"), resp.box_rect);
self.hits.insert(format!("del:{i}"), del_rect);
self.hits.insert(format!("edit:{i}"), edit_rect);
feature_rects.push((i, resp.row_rect));
if del_clicked {
delete = Some(id.clone());
}
if resp.toggled {
roll = Some(i);
}
if edit_clicked || resp.double_clicked() {
open_form = Some((i, id.clone()));
} else if resp.clicked() {
select = Some(id.clone());
}
if resp.drag_started() {
self.drag_src = Some(i);
}
if let Some(message) = user_errors.get(&id) {
let g = tree::child_guides(&[], is_last_feature);
tree::message_leaf(ui, &g, true, message, ERROR_RED);
} else {
if let Some(message) = feature_fulfilment_message(report, &id) {
let g = tree::child_guides(&[], is_last_feature);
tree::message_leaf(ui, &g, true, &message, PARTIAL_ORANGE);
}
if let Some(message) = feature_note_message(report, &id) {
let g = tree::child_guides(&[], is_last_feature);
tree::message_leaf(ui, &g, true, &message, NOTE_AMBER);
}
for (summary, _) in feature_approximations(report, &id) {
let g = tree::child_guides(&[], is_last_feature);
tree::message_leaf(ui, &g, true, &summary, APPROX_BLUE);
}
}
});
if i == current {
let bar = rollback_bar(ui);
self.hits.insert("rollback:bar".into(), bar);
}
}
if let Some(src) = self.drag_src {
let released = ui.input(|i| i.pointer.any_released());
let ptr = ui.input(|i| i.pointer.interact_pos());
match (ptr, released) {
(Some(p), released) => {
let target = feature_rects
.iter()
.min_by(|a, b| {
let da = (a.1.center().y - p.y).abs();
let db = (b.1.center().y - p.y).abs();
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(idx, _)| *idx)
.unwrap_or(src);
if released {
if target == src {
select = state.feature_id_at(src);
} else {
drag_move = Some((src, target));
}
self.drag_src = None;
} else if target != src {
if let Some((_, rect)) = feature_rects.iter().find(|(idx, _)| *idx == target)
{
let y = if target >= src { rect.bottom() } else { rect.top() };
ui.painter().hline(
rect.x_range(),
y,
egui::Stroke::new(2.0, ui.visuals().selection.bg_fill),
);
}
}
}
(None, true) => self.drag_src = None,
_ => {}
}
}
ui.add_space(6.0);
let add = ui.add_sized(
[ui.available_width(), 26.0],
egui::Button::new("Add new feature"),
);
self.hits.insert("add:menu".into(), add.rect);
if add.clicked() {
let items = feature_palette_items_scoped(&state.settings.workbench, Some(state));
self.palette.open(items, "Add feature", "Search features…");
}
if let Some((src, to)) = drag_move {
self.move_feature(state, src, to);
} else if let Some(id) = delete {
state.delete_feature(&id);
} else if let Some((i, id)) = open_form {
self.open_form(id);
state.roll_to(i);
} else if let Some(i) = roll {
state.roll_to(i);
} else if let Some(id) = select {
state.select_feature(&id);
}
let ctx = ui.ctx().clone();
if let Some(type_code) = self.palette.show(&ctx) {
self.add_feature_of_type(state, &type_code);
}
}
fn move_feature(&mut self, state: &mut EngineState, from: usize, to: usize) {
if from == to {
return;
}
let mut cur = from;
if to > from {
while cur < to {
state.reorder_feature(cur, false);
cur += 1;
}
} else {
while cur > to {
state.reorder_feature(cur, true);
cur -= 1;
}
}
}
pub fn focus_feature(&mut self, id: String) {
self.open_form(id);
}
fn open_form(&mut self, feature_id: String) {
self.mode = PanelMode::Form { feature_id };
self.form_opens = self.form_opens.wrapping_add(1);
}
pub fn form_open(&self) -> (Option<&str>, u64) {
(self.mode.feature_id(), self.form_opens)
}
fn handle_feature_button(&mut self, state: &mut EngineState, feature_id: &str, key: &str) {
match key {
"editSketch" => match state.enter_sketch_mode(feature_id) {
Ok(_) => self.mode = PanelMode::Tree,
Err(_err) => {
#[cfg(not(target_arch = "wasm32"))]
eprintln!("Edit Sketch failed for '{feature_id}': {_err}");
}
},
brep_render::brep_kernel::SKETCH3D_EDIT_BUTTON => match state.enter_sketch3d_mode(feature_id) {
Ok(_) => self.mode = PanelMode::Tree,
Err(error) => state.push_notice(format!("Edit 3D Sketch: {error}")),
},
_ => {}
}
}
pub fn hits_json(&self) -> String {
crate::automation::hit_rects::hits_json(&self.hits)
}
pub fn clear_hits(&mut self) {
self.hits.clear();
}
pub fn palette_hits_json(&self) -> String {
crate::automation::hit_rects::hits_json(self.palette.hits())
}
pub fn take_insert_component_request(&mut self) -> bool {
std::mem::take(&mut self.pending_insert_component)
}
pub(crate) fn add_feature_of_type(&mut self, state: &mut EngineState, type_code: &str) {
if type_code == "ACOMP" {
self.pending_insert_component = true;
return;
}
let short = state.feature_schema(type_code).and_then(|s| s["shortName"].as_str().filter(|s| !s.is_empty()).map(str::to_owned)).unwrap_or_else(|| features::feature_short_name(type_code));
let id = state.next_feature_id(&short);
let mut params = state.feature_default_params(type_code);
if let Value::Object(map) = &mut params {
map.insert("id".into(), Value::String(id.clone()));
}
crate::panels::context_bar::prefill_from_selection(state, type_code, &mut params);
let feature = serde_json::json!({
"type": type_code, "inputParams": params, "persistentData": {}
});
if state.add_feature(&feature.to_string()).is_ok() {
self.open_form(id);
}
}
}
fn feature_index_of(state: &EngineState, id: &str) -> Option<usize> {
(0..state.history_len()).find(|i| state.feature_id_at(*i).as_deref() == Some(id))
}
fn rollback_bar(ui: &mut egui::Ui) -> egui::Rect {
let (rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), ROLLBACK_BAR_H),
egui::Sense::hover(),
);
ui.painter().hline(
rect.x_range(),
rect.center().y,
egui::Stroke::new(2.0, ui.visuals().selection.bg_fill),
);
rect
}
fn feature_palette_items_scoped(workbench: &str, engine: Option<&EngineState>) -> Vec<PaletteItem> {
let catalogue = engine.map_or_else(features::feature_catalogue, |e| e.feature_catalogue());
let mut items = Vec::new();
if let Some(list) = catalogue.get("features").and_then(Value::as_array) {
for feature in list {
let ty = feature.get("type").and_then(Value::as_str).unwrap_or("");
if ty.is_empty() {
continue;
}
if !engine.map_or_else(|| crate::workbench::includes_feature(workbench, ty), |e| crate::workbench::includes_feature_scoped(e, workbench, ty)) {
continue;
}
let long = crate::workbench::feature_command_label(feature);
let short = feature.get("shortName").and_then(Value::as_str).unwrap_or(ty);
let mut keywords = vec![ty.to_string()];
if short != ty {
keywords.push(short.to_string());
}
items.push(PaletteItem::new(ty, long, keywords));
}
}
items
}
pub static HIT_KEYS: &[HitKeyDoc] = &[
HitKeyDoc { panel: "history", prefix: "step:", meaning: "feature i's row (step:i) \u{2014} a click SELECTS the feature (what it made becomes the viewport selection), a double click opens its form and rolls to it", command: None },
HitKeyDoc { panel: "history", prefix: "edit:", meaning: "open feature i's form (edit:i)", command: None },
HitKeyDoc { panel: "history", prefix: "del:", meaning: "delete feature i (del:i)", command: None },
HitKeyDoc { panel: "history", prefix: "box:", meaning: "feature i's visibility box (box:i)", command: None },
HitKeyDoc { panel: "history", prefix: "add:menu", meaning: "open the add-feature palette", command: None },
HitKeyDoc { panel: "history", prefix: "form:error", meaning: "the open form's error banner, while it shows an error", command: None },
HitKeyDoc { panel: "history", prefix: "form:report-problem", meaning: "the error banner's Report problem button: opens the problem report pre-filled with the dialog and its error", command: None },
HitKeyDoc { panel: "history", prefix: "form:return", meaning: "close the open form and roll to the tip", command: None },
HitKeyDoc { panel: "history", prefix: "form:read-only", meaning: "the open form's read-only banner (a PLM revision not checked out, or released): the fields take no edit", command: None },
HitKeyDoc { panel: "history", prefix: "form:feature", meaning: "the open form's title row (form:feature) and its feature chip (form:feature:id)", command: None },
HitKeyDoc { panel: "history", prefix: "form:feature:", meaning: "the open form's feature chip (form:feature:id)", command: None },
HitKeyDoc { panel: "history", prefix: "form:section:", meaning: "toggle an accordion section of the open form", command: None },
HitKeyDoc { panel: "history", prefix: "field:", meaning: "a field of the open form, by param path", command: None },
HitKeyDoc { panel: "history", prefix: "form:trailing:", meaning: "a read-only line of the open form (form:trailing:Outputs:i) — hovering it lights that entity", command: None },
HitKeyDoc { panel: "history", prefix: "rollback:bar", meaning: "the executed-up-to indicator", command: None },
HitKeyDoc { panel: "history", prefix: "panel:clip", meaning: "the visible region of the pane", command: None },
];