//! Reviewable sketch-plane rebinding. Saved lost planes are repair guides, never model geometry. use egui::{Pos2, RichText}; use serde_json::{Value, json}; use solidcraft_engine::Sel; use solidcraft_engine::doc::face_anchor::plane_face_anchor; use solidcraft_engine::doc::{EmbossFace, FaceAttachmentStatus, FeatureKind, mat_inverse}; use solidcraft_engine::geom::{Plane, Vec3}; use crate::SolveApp; use crate::theme::Tokens; #[derive(Clone, Debug)] pub struct Draft { pub sketch: u64, pub candidate: Option, pub label: String, pub error: Option, pub author_path: Option>, } impl Draft { pub fn new(app: &SolveApp, sketch: u64) -> Self { let author_path = app.session.doc.feature(sketch).and_then(|feature| match &feature.kind { FeatureKind::Sketch { plane, .. } if plane_face_anchor(plane).is_some() => { plane_face_anchor(plane).map(|anchor| anchor.author_path.clone()) } _ if feature.component == app.session.active_component => app.session.active_occurrence_path.clone(), _ => None, }); Self { sketch, candidate: None, label: String::new(), error: None, author_path } } fn choose(&mut self, plane: Value, label: String) { self.candidate = Some(plane); self.label = label; self.error = None; } pub fn commands(&self) -> Option> { let mut params = json!({"sketch":self.sketch,"plane":self.candidate.as_ref()?}); if let Some(path) = &self.author_path { params["author_path"] = json!(path); } Some(vec![("sketch.redefine".into(), params)]) } pub fn choose_saved_fixed(&mut self, app: &SolveApp, world: Plane) -> Result<(), String> { let doc = app.session.placed_document(); let owner = doc.feature(self.sketch).ok_or("Sketch no longer exists")?.component; let path = self.author_path.clone().unwrap_or_else(|| doc.first_path(owner)); if self.author_path.is_none() && path.iter().any(|id| { doc.occurrences .iter() .find(|o| o.id == *id) .is_none_or(|selected| doc.occurrences.iter().filter(|o| o.component == selected.component).count() != 1) }) { return Err("Select an exact author placement before converting this plane".into()); } let frame = EmbossFace::component_frame(&doc, owner, &path).map_err(|error| error.to_string())?; mat_inverse(&frame).ok_or("Sketch author frame is invalid")?; self.author_path = Some(path); // Public commands take world geometry. Session maps Custom planes into // this exact author placement once, including redefine's saved path. self.choose(fixed_plane(world), "Fixed plane: independent of the source body".into()); Ok(()) } } pub fn clear(app: &mut SolveApp) { app.tree.redefine = None; app.tree.redefine_draft = None; } pub fn commands(app: &SolveApp) -> Option> { let draft = app.tree.redefine_draft.as_ref()?; (app.tree.redefine == Some(draft.sketch)).then(|| draft.commands()).flatten() } fn attachment(app: &SolveApp, sketch: u64) -> FaceAttachmentStatus { app.session.placed_document().sketch_face_attachment(sketch, &app.session.model) } fn fixed_plane(plane: Plane) -> Value { json!({"origin":plane.origin,"x_dir":plane.x,"y_dir":plane.y}) } pub fn face_plane(app: &SolveApp, selection: &Sel) -> Option { match selection { Sel::Face { body, index, point } => { let doc = app.session.placed_document(); let (_, _, _, path, _) = doc.body_frames_with_source(&app.session.model.state()).into_iter().find(|(world, _, _, _, _)| world == body)?; Some(json!({"face":point,"body":body,"index":index,"path":path})) } _ => None, } } fn face_label(app: &SolveApp, selection: &Sel) -> String { let Sel::Face { body, .. } = selection else { return String::new() }; let placed = app.session.placed_document(); if let Some((_, source, _, path, _)) = placed.body_frames_with_source(&app.session.model.state()).into_iter().find(|(world, _, _, _, _)| world == body) { format!("Face on {source} / {}", path_label(app, &path)) } else { format!("Face on {body}") } } fn path_label(app: &SolveApp, path: &[u64]) -> String { if path.is_empty() { return "Root".into(); } (1..=path.len()) .map(|end| app.session.occurrence_placement(&path[..end]).map(|placement| placement.name).unwrap_or_else(|_| "Missing occurrence".into())) .collect::>() .join(" / ") } pub fn panel(app: &mut SolveApp, ctx: &egui::Context, at: Pos2) { let Some(sketch) = app.tree.redefine else { return }; if app.session.doc.feature(sketch).is_none() { clear(app); return; } let mut draft = app.tree.redefine_draft.take().filter(|draft| draft.sketch == sketch).unwrap_or_else(|| Draft::new(app, sketch)); let status = attachment(app, sketch); let name = app.session.doc.feature(sketch).map(|feature| feature.name.as_str()).unwrap_or("Sketch"); let selected = app.session.selection.iter().find(|selection| matches!(selection, Sel::Face { .. })).cloned(); let mut open = true; let mut apply = false; let mut cancel = false; let t = Tokens::get(); crate::frame::docked( format!("Redefine Sketch Plane: {name}"), egui::Id::new("sc_redefine"), at, app.viewport.rect.map(|rect| rect.bottom()).unwrap_or_else(|| ctx.content_rect().bottom()) - at.y - 8.0, crate::frame::Width::Normal, ) .open(&mut open) .scroll([false, true]) .show(ctx, |ui| { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); // The wrapped notes below fill the width anyway, so the band may too. let width = ui.available_width(); crate::frame::heading(ui, crate::frame::Level::Title, width, |ui| ui.strong(format!("Redefine Sketch Plane: {name}"))); let saved_plane = match &status { FaceAttachmentStatus::Healthy { plane, target, path } => { ui.label(format!("Attached to {} / {}", target.body, path_label(app, path))); Some(*plane) } FaceAttachmentStatus::Lost { message, repair_plane } => { ui.label(RichText::new(crate::i18n::tr_label("Face attachment lost")).color(t.error)); ui.label(message); if repair_plane.is_some() { ui.label(crate::i18n::tr_label("The dashed sketch is a saved plane guide. Select a new face to repair the attachment.")); } else { ui.label(crate::i18n::tr_label("The saved author occurrence is missing. Select a valid author placement before rebinding.")); } *repair_plane } FaceAttachmentStatus::Legacy { plane } => { ui.label(crate::i18n::tr_label("Legacy face attachment. Rebinding to a certified face creates an exact attachment.")); *plane } FaceAttachmentStatus::Sheet { plane } => { ui.label("Attached to a flat face of a sheet metal body; it follows that panel when the sheet changes."); *plane } FaceAttachmentStatus::Fixed { plane } => { ui.label(crate::i18n::tr_label("Sketch plane has no body-face attachment.")); *plane } }; ui.separator(); if matches!(&status, FaceAttachmentStatus::Lost { repair_plane: None, .. }) { let author = app.session.doc.feature(sketch).map(|feature| feature.component); let available = author == Some(app.session.active_component); if ui.add_enabled(available, egui::Button::new(crate::i18n::tr_label("Use active author placement"))).clicked() { draft.author_path = Some(app.session.active_occurrence_path.clone().unwrap_or_default()); draft.error = None; } if available && let Some(path) = &draft.author_path { ui.label(format!("Author placement: {}", path_label(app, path))); } } ui.label(crate::i18n::tr_label("Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.")); ui.horizontal_wrapped(|ui| { for plane in ["XY", "XZ", "YZ"] { if ui.button(format!("Use {plane} plane")).clicked() { draft.choose(json!(plane), plane.into()); } } }); for feature in &app.session.doc.features { if matches!(feature.kind, FeatureKind::ConstructionPlane { .. }) && ui.button(&feature.name).clicked() { draft.choose(json!(feature.name), feature.name.clone()); } } if ui.add_enabled(selected.is_some(), egui::Button::new(crate::i18n::tr_label("Reattach selected face"))).clicked() && let Some(selection @ Sel::Face { .. }) = &selected && let Some(plane) = face_plane(app, selection) { let label = face_label(app, selection); draft.choose(plane, label); } if ui.add_enabled(saved_plane.is_some(), egui::Button::new(crate::i18n::tr_label("Use saved fixed plane"))).clicked() && let Some(plane) = saved_plane && let Err(error) = draft.choose_saved_fixed(app, plane) { draft.error = Some(error); } if ui.add_enabled(selected.is_some(), egui::Button::new(crate::i18n::tr_label("Use selected face as fixed plane"))).clicked() && let Some(selection) = &selected && let Some(mut plane) = face_plane(app, selection) { plane["fixed"] = json!(true); draft.choose(plane, "Fixed plane: independent of the selected body".into()); } if draft.candidate.is_some() { ui.separator(); ui.label(format!("Preview: {}", draft.label)); } if let Some(error) = draft.error.as_ref().or(app.preview.error.as_ref()) { ui.label(RichText::new(error).color(t.error)); } ui.horizontal(|ui| { if ui.add_enabled(draft.candidate.is_some() && app.preview.error.is_none(), egui::Button::new("Apply")).clicked() { apply = true; } if ui.button("Cancel").clicked() { cancel = true; } }); }); if ctx.input(|input| input.key_pressed(egui::Key::Escape)) { cancel = true; app.esc_handled = true; } if apply && let Some(commands) = draft.commands() { for (command, params) in commands { match app.run(&command, params) { Ok(_) => cancel = true, Err(error) => draft.error = Some(error), } } } if cancel || !open { clear(app); } else { app.tree.redefine_draft = Some(draft); } } /// Only a lost reference's explicit saved author frame supplies a guide. It is /// absent from ModelState, selection, snapping and all downstream evaluation. pub fn lost_guide(app: &SolveApp) -> Option<(Plane, &solidcraft_engine::sketch::Sketch)> { let sketch = app.tree.redefine?; let FaceAttachmentStatus::Lost { repair_plane: Some(plane), .. } = attachment(app, sketch) else { return None }; let FeatureKind::Sketch { sketch, .. } = &app.session.doc.feature(sketch)?.kind else { return None }; Some((plane, sketch)) } pub fn guide_lines(app: &SolveApp) -> Vec> { let Some((plane, sketch)) = lost_guide(app) else { return Vec::new() }; (0..sketch.curves.len()) .flat_map(|index| sketch.segs(index)) .map(|segment| segment.polyline(0.05).into_iter().map(|point| plane.to_world(point)).collect()) .collect() } pub fn display_guide_lines(app: &SolveApp, proj: &crate::viewport::Proj) -> Vec> { let Some((plane, sketch)) = lost_guide(app) else { return Vec::new() }; let mut budget = solidcraft_engine::render::SamplingBudget::new(65_536, 262_144); (0..sketch.curves.len()) .flat_map(|index| crate::sketch_display::curve_with_budget(sketch, index, &plane, proj, app.viewport.pixels_per_point(), &mut budget).parts) .collect() }