use super::action_rail::{action_rail, ActionItem};
use super::toolbar_button;
use crate::panels::tree::{self, TreeRow};
use brep_render::engine_state::{EngineState, SketchEntityRow};
use eframe::egui;
use std::collections::HashMap;
#[derive(Default)]
pub struct SketchPanel {
ctx_hits: HashMap<String, egui::Rect>,
curves_collapsed: bool,
points_collapsed: bool,
constraints_collapsed: bool,
solver_collapsed: bool,
}
impl SketchPanel {
pub fn new() -> Self {
Self {
solver_collapsed: true,
..Self::default()
}
}
pub fn show_mode_bar(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
egui::containers::panel::Panel::top("sketch-mode-bar")
.resizable(false)
.show(ui, |ui| {
ui.add_space(3.0);
ui.horizontal_wrapped(|ui| {
let active = state.sketch_active_tool().unwrap_or("select").to_string();
for (tool, glyph, tip) in [
("select", "\u{1F446}", "Select / drag entities"),
("point", "\u{2316}", "Place a point"),
("line", "/", "Draw connected line segments (Esc ends)"),
("rect", "\u{2610}", "Draw a rectangle (two opposite corners)"),
("circle", "\u{25EF}", "Draw a circle (center, then radius)"),
("arc", "\u{25E0}", "Draw an arc (center, start, end)"),
("bezier", "\u{223F}", "Bezier — end, ctrl, ctrl, end"),
("handdraw", "\u{270D}", "Freehand (auto line/circle/arc)"),
("trim", "\u{2702}", "Trim curve"),
("pickEdges", "\u{26D3}", "Link external edge"),
] {
let selected = active.as_str() == tool;
if toolbar_button::toggle(ui, selected, glyph, tip).clicked() {
state.sketch_set_tool(Some(tool));
}
}
ui.separator();
if toolbar_button::button(
ui,
"\u{1F916}",
"Auto-constrain: infer coincident + horizontal/vertical from the geometry",
)
.clicked()
{
state.sketch_auto_constrain();
}
let pending = state.sketch_pending_len();
if pending > 0 {
ui.separator();
ui.label(
egui::RichText::new(format!("… {pending} placed"))
.weak()
.italics(),
);
}
});
ui.add_space(3.0);
});
}
pub fn show_status_bar(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
ui.horizontal_wrapped(|ui| {
let id = state.sketch_edit_feature_id().unwrap_or("").to_string();
ui.label(egui::RichText::new(format!("Sketch: {id}")).strong());
ui.separator();
if let Some(session) = state.sketch_edit_session() {
dof_readout(ui, &session.diagnostics);
}
ui.separator();
ui.label(
egui::RichText::new(format!("{} selected", state.sketch_selection_count())).weak(),
);
ui.separator();
let mut locked = state.sketch_camera_locked();
if ui
.checkbox(&mut locked, "Lock to sketch")
.on_hover_text(
"Face the sketch plane and pan only. Uncheck to orbit; \
re-check to snap back flat.",
)
.changed()
{
state.toggle_sketch_camera_lock();
}
});
}
pub fn context_card(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
self.ctx_hits.clear();
if !state.sketch_mode() || state.sketch_selection_count() == 0 {
return;
}
let actions = state.sketch_applicable_constraints();
let grounded = state.sketch_selection_all_grounded();
let construction = state.sketch_selection_all_construction();
let mut items: Vec<ActionItem> = Vec::new();
for action in &actions {
items.push(ActionItem::new(
format!("constraint:{}", action.symbol),
format!("{} {}", action.symbol, action.label),
action.label.clone(),
));
}
if let Some(all_grounded) = grounded {
let (label, tip) = if all_grounded {
("Unfix", "Remove the ground constraint")
} else {
("Fix", "Ground (fix) the selected points")
};
items.push(ActionItem::new("fix", label, tip));
}
if let Some(all_construction) = construction {
let tip = if all_construction {
"Convert to regular geometry"
} else {
"Convert to construction geometry"
};
items.push(ActionItem::new("construction", "◐ Construction", tip));
}
items.push(ActionItem::new(
"cleanup",
"🧹 Clean",
"Remove unused points",
));
items.push(ActionItem::new(
"delete",
"🗑 Delete",
"Delete the selected entities (Del / Backspace)",
));
let subtitle = format!("{} selected", state.sketch_selection_count());
let clicked = egui::Frame::popup(ui.style())
.show(ui, |ui| {
action_rail(
ui,
Some("Sketch actions"),
Some(&subtitle),
&items,
&mut self.ctx_hits,
)
})
.inner;
match clicked.as_deref() {
Some(key) if key.starts_with("constraint:") => {
state.sketch_add_constraint(&key["constraint:".len()..]);
}
Some("fix") => {
state.sketch_toggle_ground();
}
Some("construction") => {
state.sketch_toggle_construction();
}
Some("cleanup") => {
state.sketch_cleanup_unused_points();
}
Some("delete") => {
state.sketch_delete_selection();
}
_ => {}
}
}
pub fn show_entity_lists(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
egui::ScrollArea::vertical().show(ui, |ui| {
ui.add_space(4.0);
let curves = state.sketch_geometry_rows();
let points = state.sketch_point_rows();
let constraints = state.sketch_constraint_rows();
entity_section(ui, &mut self.curves_collapsed, state, "Curves", "geometry", curves);
entity_section(ui, &mut self.points_collapsed, state, "Points", "point", points);
entity_section(
ui,
&mut self.constraints_collapsed,
state,
"Constraints",
"constraint",
constraints,
);
ui.add_space(6.0);
solver_settings_section(ui, &mut self.solver_collapsed, state);
});
}
#[cfg(target_arch = "wasm32")]
pub fn published_json(&self, state: &EngineState) -> String {
let session = state.sketch_edit_session();
let dim_labels: Vec<serde_json::Value> =
serde_json::from_str(&state.sketch_dimension_labels_json()).unwrap_or_default();
let dimensions: Vec<serde_json::Value> = dim_labels
.iter()
.map(|l| {
serde_json::json!({
"id": l["id"],
"text": l["text"],
"value": l["value"],
"valueExpr": l["valueExpr"],
"mode": l["mode"],
})
})
.collect();
serde_json::json!({
"sketchMode": state.sketch_mode(),
"featureId": state.sketch_edit_feature_id(),
"dof": session.map(|s| s.diagnostics.dof),
"status": session.map(|s| s.diagnostics.status.clone()),
"conflicting": session.map(|s| s.diagnostics.conflicting),
"points": session.map(|s| s.doc.points.len()),
"geometries": session.map(|s| s.doc.geometries.len()),
"constraints": session.map(|s| s.doc.constraints.len()),
"hovered": session.and_then(|s| s.hovered.clone()),
"selectionCount": state.sketch_selection_count(),
"selectedConstraintCount": state.sketch_selected_constraint_count(),
"tool": state.sketch_active_tool(),
"cameraLocked": state.sketch_camera_locked(),
"pointCount": session.map(|s| s.doc.points.len()),
"geometryCount": session.map(|s| s.doc.geometries.len()),
"pendingLen": state.sketch_pending_len(),
"constraintCount": state.sketch_constraint_count(),
"applicableConstraints": state
.sketch_applicable_constraints()
.iter()
.map(|a| a.symbol.clone())
.collect::<Vec<_>>(),
"dimensionCount": dimensions.len(),
"dimensions": dimensions,
"canUndo": state.sketch_can_undo(),
"canRedo": state.sketch_can_redo(),
"externalRefCount": state.sketch_external_ref_count(),
"handdrawPoints": state.sketch_handdraw_len(),
})
.to_string()
}
}
fn dof_readout(ui: &mut egui::Ui, diag: &brep_render::sketch::SketchDiagnostics) {
let (color, label): (egui::Color32, String) = if diag.conflicting {
(
egui::Color32::from_rgb(0xff, 0x5c, 0x5c),
"Conflicting constraints".to_string(),
)
} else if diag.status == "over" || diag.redundant > 0 {
let label = if diag.dof > 0 {
format!(
"Over-constrained ({} redundant, {} DOF)",
diag.redundant, diag.dof
)
} else {
format!("Over-constrained ({} redundant)", diag.redundant)
};
(egui::Color32::from_rgb(0xff, 0xcf, 0x5c), label)
} else if diag.status == "under" || diag.dof > 0 {
(
egui::Color32::from_rgb(0x4a, 0xa3, 0xff),
format!("Under-constrained — {} DOF", diag.dof),
)
} else {
(
egui::Color32::from_rgb(0x7e, 0xe0, 0xa6),
"Fully constrained".to_string(),
)
};
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::hover());
ui.painter().circle_filled(rect.center(), 5.0, color);
ui.label(egui::RichText::new(label).strong());
});
}
fn entity_section(
ui: &mut egui::Ui,
collapsed: &mut bool,
state: &mut EngineState,
title: &str,
kind: &'static str,
rows: Vec<SketchEntityRow>,
) {
let open = !*collapsed;
let resp = tree::node(ui, TreeRow::branch(&[], true, open, title), |ui| {
ui.add_space(6.0);
ui.label(egui::RichText::new(format!("{}", rows.len())).weak());
});
if resp.toggled || resp.label.clicked() {
*collapsed = !*collapsed;
}
if !open {
return;
}
let base = tree::child_guides(&[], true);
if rows.is_empty() {
tree::node(ui, TreeRow::leaf(&base, true, "—"), |_| {});
return;
}
let n = rows.len();
let mut to_delete: Option<serde_json::Value> = None;
for (i, row) in rows.iter().enumerate() {
let last = i + 1 == n;
let mut delete_clicked = false;
let resp = tree::node(
ui,
TreeRow::leaf(&base, last, row.label.as_str()).selected(row.selected),
|ui| {
if ui.small_button("✕").on_hover_text("Delete").clicked() {
delete_clicked = true;
}
},
);
if delete_clicked {
to_delete = Some(row.id.clone());
} else if resp.label.clicked() {
let additive = ui.input(|i| i.modifiers.command || i.modifiers.ctrl);
state.sketch_select_entity(kind, row.id.clone(), additive);
}
if resp.label.hovered() {
state.sketch_hover_entity(kind, row.id.clone());
}
}
if let Some(id) = to_delete {
state.sketch_select_entity(kind, id, false);
state.sketch_delete_selection();
}
}
fn solver_settings_section(ui: &mut egui::Ui, collapsed: &mut bool, state: &mut EngineState) {
let Some(mut settings) = state.sketch_solver_settings() else {
return;
};
let open = !*collapsed;
let resp = tree::node(ui, TreeRow::branch(&[], true, open, "Solver Settings"), |_| {});
if resp.toggled || resp.label.clicked() {
*collapsed = !*collapsed;
}
if !open {
return;
}
let before = settings.clone();
egui::Frame::group(ui.style()).show(ui, |ui| {
let mut iters = settings.iterations.unwrap_or(1000);
ui.horizontal(|ui| {
ui.label("Max iterations");
if ui
.add(egui::DragValue::new(&mut iters).range(50..=20_000).speed(10.0))
.changed()
{
settings.iterations = Some(iters);
}
});
let mut tol_on = settings.tolerance.is_some();
ui.horizontal(|ui| {
if ui.checkbox(&mut tol_on, "Override tolerance").changed() {
settings.tolerance = tol_on.then_some(1e-6);
}
if let Some(mut tol) = settings.tolerance {
if ui
.add(
egui::DragValue::new(&mut tol)
.range(1e-9..=1e-1)
.speed(1e-6)
.custom_formatter(|v, _| format!("{v:.1e}")),
)
.changed()
{
settings.tolerance = Some(tol);
}
}
});
if ui.button("Reset to defaults").clicked() {
settings = brep_render::sketch::SketchSolverSettings::default();
}
});
if settings != before {
state.sketch_set_solver_settings(settings);
}
}