Completes the Japanese catalog (all commands, panels, dialogs and preferences) and routes the remaining hard-coded UI labels through the translation lookup. English stays the default.
283 lines
12 KiB
Rust
283 lines
12 KiB
Rust
//! 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<Value>,
|
|
pub label: String,
|
|
pub error: Option<String>,
|
|
pub author_path: Option<Vec<u64>>,
|
|
}
|
|
|
|
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<Vec<(String, Value)>> {
|
|
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<Vec<(String, Value)>> {
|
|
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<Value> {
|
|
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::<Vec<_>>()
|
|
.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<Vec<Vec3>> {
|
|
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<Vec<Vec3>> {
|
|
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()
|
|
}
|