From b8fb6f5ddb1324409225a81f9d24838451c88853 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:35:56 +0300 Subject: [PATCH] Complete 3D face creation and editing --- src/app/commands/draw.rs | 25 ++ src/app/properties.rs | 3 + src/app/update/mod.rs | 3 + src/entities/mesh.rs | 120 +++++--- src/modules/draw/draw/face3d.rs | 509 ++++++++++++++++++++++++++++++++ src/modules/draw/draw/mod.rs | 1 + 6 files changed, 616 insertions(+), 45 deletions(-) create mode 100644 src/modules/draw/draw/face3d.rs diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index d9edbc862..d74a2258f 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -454,6 +454,31 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "3DFACE" => { + use crate::modules::draw::draw::face3d::Face3dCommand; + let new_cmd = Face3dCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + "EDGE" => { + use crate::modules::draw::draw::face3d::FaceEdgeCommand; + let faces = self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| match entity { + acadrust::EntityType::Face3D(face) => { + Some((face.common.handle, face.clone())) + } + _ => None, + }) + .collect(); + let new_cmd = FaceEdgeCommand::new(faces); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + // 2D filled solid. Reached via SO / SOLID2D — the bare SOLID verb is // currently the shaded-display toggle (token collision tracked). "SOLID" | "SOLID2D" => { diff --git a/src/app/properties.rs b/src/app/properties.rs index d884905a7..58caa3d41 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -122,6 +122,9 @@ impl OpenCADStudio { acadrust::EntityType::LwPolyline(polyline) => Some(polyline.vertices.len()), acadrust::EntityType::Polyline2D(polyline) => Some(polyline.vertices.len()), acadrust::EntityType::Leader(leader) => Some(leader.vertices.len()), + acadrust::EntityType::Face3D(face) => { + Some(if face.is_triangle() { 3 } else { 4 }) + } acadrust::EntityType::Spline(spline) => { Some(if crate::entities::spline::shows_fit_points(spline) { spline.fit_points.len() diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index cb501dc7d..ff4342b82 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4901,6 +4901,9 @@ impl OpenCADStudio { match self.tabs[i].scene.document.get_entity(handles[0]) { Some(acadrust::EntityType::LwPolyline(p)) => p.vertices.len(), Some(acadrust::EntityType::Polyline2D(p)) => p.vertices.len(), + Some(acadrust::EntityType::Face3D(face)) => { + if face.is_triangle() { 3 } else { 4 } + } Some(acadrust::EntityType::Table(table)) => { table.row_count().saturating_mul(table.column_count()) } diff --git a/src/entities/mesh.rs b/src/entities/mesh.rs index 664ab8757..e4bafc8f4 100644 --- a/src/entities/mesh.rs +++ b/src/entities/mesh.rs @@ -5,7 +5,9 @@ use glam::Vec3; use crate::t; use crate::command::EntityTransform; -use crate::entities::common::{parse_f64, ro_prop as ro, square_grip}; +use crate::entities::common::{ + edit_prop as edit, parse_f64, ro_prop as ro, stepper_prop as stepper, square_grip, +}; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; @@ -238,15 +240,6 @@ impl RenderConvertible for Face3D { add_edge(p3, p0); } - if pts.is_empty() { - // All edges invisible — show a tiny cross at centroid. - let cx = (p0[0] + p1[0] + p2[0] + p3[0]) / 4.0; - let cy = (p0[1] + p1[1] + p2[1] + p3[1]) / 4.0; - let cz = (p0[2] + p1[2] + p2[2] + p3[2]) / 4.0; - let s = 0.1_f64; - pts = vec![[cx - s, cy, cz], [cx + s, cy, cz]]; - } - Some(RenderEntity { pick_tris: Vec::new(), object: RenderObject::Lines(pts), @@ -265,15 +258,19 @@ impl RenderConvertible for Face3D { impl Grippable for Face3D { fn grips(&self) -> Vec { - vec![ + let mut grips = vec![ square_grip(0, dvec3(&self.first_corner)), square_grip(1, dvec3(&self.second_corner)), square_grip(2, dvec3(&self.third_corner)), - square_grip(3, dvec3(&self.fourth_corner)), - ] + ]; + if !self.is_triangle() { + grips.push(square_grip(3, dvec3(&self.fourth_corner))); + } + grips } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { + let was_triangle = self.is_triangle(); let corner = match grip_id { 0 => &mut self.first_corner, 1 => &mut self.second_corner, @@ -293,57 +290,90 @@ impl Grippable for Face3D { corner.z = p.z as f64; } } + if was_triangle && grip_id == 2 { + self.fourth_corner = self.third_corner; + } } } impl PropertyEditable for Face3D { fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { - use crate::entities::common::edit_prop as edit; + let was_triangle = self.is_triangle(); + let vertex_count = if was_triangle { 3 } else { 4 }; + let current = crate::scene::view::dispatch::prop_current_vertex() + .min(vertex_count - 1); + let corner = match current { + 0 => &self.first_corner, + 1 => &self.second_corner, + 2 => &self.third_corner, + _ => &self.fourth_corner, + }; let inv = self.invisible_edges; - let edge = |hidden: bool| if hidden { "Invisible" } else { "Visible" }; + let edge = |label: &str, field: &'static str, hidden: bool| Property { + label: label.to_string(), + field, + value: PropValue::Choice { + selected: if hidden { + t!("Invisible").into_owned() + } else { + t!("Visible").into_owned() + }, + options: vec![t!("Visible").into_owned(), t!("Invisible").into_owned()], + }, + }; vec![PropSection { title: t!("Geometry").into_owned(), props: vec![ - ro(t!("Current vertex").as_ref(), "f3_current", String::new()), - edit(t!("Vertex 1 X").as_ref(), "f3_p1x", self.first_corner.x), - edit(t!("Vertex 1 Y").as_ref(), "f3_p1y", self.first_corner.y), - edit(t!("Vertex 1 Z").as_ref(), "f3_p1z", self.first_corner.z), - edit(t!("Vertex 2 X").as_ref(), "f3_p2x", self.second_corner.x), - edit(t!("Vertex 2 Y").as_ref(), "f3_p2y", self.second_corner.y), - edit(t!("Vertex 2 Z").as_ref(), "f3_p2z", self.second_corner.z), - edit(t!("Vertex 3 X").as_ref(), "f3_p3x", self.third_corner.x), - edit(t!("Vertex 3 Y").as_ref(), "f3_p3y", self.third_corner.y), - edit(t!("Vertex 3 Z").as_ref(), "f3_p3z", self.third_corner.z), - edit(t!("Vertex 4 X").as_ref(), "f3_p4x", self.fourth_corner.x), - edit(t!("Vertex 4 Y").as_ref(), "f3_p4y", self.fourth_corner.y), - edit(t!("Vertex 4 Z").as_ref(), "f3_p4z", self.fourth_corner.z), - ro(t!("Edge 1").as_ref(), "f3_edge1", edge(inv.is_first_invisible())), - ro(t!("Edge 2").as_ref(), "f3_edge2", edge(inv.is_second_invisible())), - ro(t!("Edge 3").as_ref(), "f3_edge3", edge(inv.is_third_invisible())), - ro(t!("Edge 4").as_ref(), "f3_edge4", edge(inv.is_fourth_invisible())), + stepper( + t!("Current vertex").as_ref(), + "current_vertex", + format!("{}", current + 1), + ), + edit(t!("Vertex X").as_ref(), "f3_vertex_x", corner.x), + edit(t!("Vertex Y").as_ref(), "f3_vertex_y", corner.y), + edit(t!("Vertex Z").as_ref(), "f3_vertex_z", corner.z), + edge(t!("Edge 1").as_ref(), "f3_edge1", inv.is_first_invisible()), + edge(t!("Edge 2").as_ref(), "f3_edge2", inv.is_second_invisible()), + edge(t!("Edge 3").as_ref(), "f3_edge3", inv.is_third_invisible()), + edge(t!("Edge 4").as_ref(), "f3_edge4", inv.is_fourth_invisible()), ], }] } fn apply_geom_prop(&mut self, field: &str, value: &str) { - let Ok(v) = value.trim().parse::() else { + let invisible = value == t!("Invisible").as_ref(); + match field { + "f3_edge1" => self.invisible_edges.set_first_invisible(invisible), + "f3_edge2" => self.invisible_edges.set_second_invisible(invisible), + "f3_edge3" => self.invisible_edges.set_third_invisible(invisible), + "f3_edge4" => self.invisible_edges.set_fourth_invisible(invisible), + _ => {} + } + if field.starts_with("f3_edge") { + return; + } + let Some(value) = parse_f64(value) else { return; }; + let was_triangle = self.is_triangle(); + let vertex_count = if was_triangle { 3 } else { 4 }; + let current = crate::scene::view::dispatch::prop_current_vertex() + .min(vertex_count - 1); + let corner = match current { + 0 => &mut self.first_corner, + 1 => &mut self.second_corner, + 2 => &mut self.third_corner, + _ => &mut self.fourth_corner, + }; match field { - "f3_p1x" => self.first_corner.x = v, - "f3_p1y" => self.first_corner.y = v, - "f3_p1z" => self.first_corner.z = v, - "f3_p2x" => self.second_corner.x = v, - "f3_p2y" => self.second_corner.y = v, - "f3_p2z" => self.second_corner.z = v, - "f3_p3x" => self.third_corner.x = v, - "f3_p3y" => self.third_corner.y = v, - "f3_p3z" => self.third_corner.z = v, - "f3_p4x" => self.fourth_corner.x = v, - "f3_p4y" => self.fourth_corner.y = v, - "f3_p4z" => self.fourth_corner.z = v, + "f3_vertex_x" => corner.x = value, + "f3_vertex_y" => corner.y = value, + "f3_vertex_z" => corner.z = value, _ => {} } + if was_triangle && current == 2 { + self.fourth_corner = self.third_corner; + } } } diff --git a/src/modules/draw/draw/face3d.rs b/src/modules/draw/draw/face3d.rs new file mode 100644 index 000000000..9813ba60f --- /dev/null +++ b/src/modules/draw/draw/face3d.rs @@ -0,0 +1,509 @@ +use acadrust::entities::{face3d::InvisibleEdgeFlags, Face3D}; +use acadrust::types::Vector3; +use acadrust::{EntityType, Handle}; +use glam::DVec3; +use std::collections::HashSet; + +use crate::command::{CadCommand, CmdOption, CmdResult}; +use crate::scene::model::wire_model::WireModel; +use crate::t; + +const EPSILON: f64 = 1.0e-9; + +fn vector(point: DVec3) -> Vector3 { + Vector3::new(point.x, point.y, point.z) +} + +fn point(value: Vector3) -> DVec3 { + DVec3::new(value.x, value.y, value.z) +} + +fn set_edge_invisible(flags: &mut InvisibleEdgeFlags, edge: usize, invisible: bool) { + match edge { + 0 => flags.set_first_invisible(invisible), + 1 => flags.set_second_invisible(invisible), + 2 => flags.set_third_invisible(invisible), + 3 => flags.set_fourth_invisible(invisible), + _ => {} + } +} + +fn edge_is_invisible(flags: InvisibleEdgeFlags, edge: usize) -> bool { + match edge { + 0 => flags.is_first_invisible(), + 1 => flags.is_second_invisible(), + 2 => flags.is_third_invisible(), + 3 => flags.is_fourth_invisible(), + _ => false, + } +} + +fn append_segment(points: &mut Vec<[f64; 3]>, start: DVec3, end: DVec3) { + if !points.is_empty() { + points.push([f64::NAN; 3]); + } + points.push(start.to_array()); + points.push(end.to_array()); +} + +pub struct Face3dCommand { + points: Vec, + invisible: [bool; 4], + pending_invisible: bool, +} + +impl Face3dCommand { + pub fn new() -> Self { + Self { + points: Vec::new(), + invisible: [false; 4], + pending_invisible: false, + } + } + + fn build(&self, fourth: DVec3) -> Option { + if self.points.len() != 3 { + return None; + } + let mut face = Face3D::new( + vector(self.points[0]), + vector(self.points[1]), + vector(self.points[2]), + vector(fourth), + ); + for edge in 0..4 { + set_edge_invisible(&mut face.invisible_edges, edge, self.invisible[edge]); + } + Some(EntityType::Face3D(face)) + } + + fn continue_from_last_edge(&mut self, fourth: DVec3) { + let third = self.points[2]; + let shared_edge_invisible = self.invisible[2]; + self.points.clear(); + self.points.push(third); + self.points.push(fourth); + self.invisible = [shared_edge_invisible, false, false, false]; + self.pending_invisible = false; + } + + fn continue_from_triangle(&mut self) { + let second = self.points[1]; + let third = self.points[2]; + let shared_edge_invisible = self.invisible[1]; + self.points.clear(); + self.points.push(second); + self.points.push(third); + self.invisible = [shared_edge_invisible, false, false, false]; + self.pending_invisible = false; + } +} + +impl CadCommand for Face3dCommand { + fn name(&self) -> &'static str { + "3DFACE" + } + + fn prompt(&self) -> String { + match self.points.len() { + 0 => t!("3DFACE Specify first point or [Invisible]:").into_owned(), + 1 => t!("3DFACE Specify second point or [Invisible]:").into_owned(), + 2 => t!("3DFACE Specify third point or [Invisible] :").into_owned(), + _ => t!("3DFACE Specify fourth point or [Invisible] :") + .into_owned(), + } + } + + fn options(&self) -> Vec { + vec![CmdOption::new(t!("Invisible").as_ref(), "I")] + } + + fn on_point(&mut self, picked: DVec3) -> CmdResult { + let point_index = self.points.len(); + if self.pending_invisible && point_index < 4 { + self.invisible[point_index] = true; + } + self.pending_invisible = false; + if self.points.len() == 3 { + let entity = self.build(picked); + self.continue_from_last_edge(picked); + return entity.map_or(CmdResult::Cancel, CmdResult::CommitEntity); + } + self.points.push(picked); + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + if self.points.len() != 3 { + return CmdResult::Cancel; + } + if self.pending_invisible { + self.invisible[3] = true; + } + let third = self.points[2]; + let entity = self.build(third); + self.continue_from_triangle(); + entity.map_or(CmdResult::Cancel, CmdResult::CommitEntity) + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + true + } + + fn on_text_input(&mut self, value: &str) -> Option { + match value.trim().to_ascii_uppercase().as_str() { + "I" | "INVISIBLE" => { + self.pending_invisible = true; + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + + fn on_mouse_move(&mut self, cursor: DVec3) -> Option { + if self.points.is_empty() { + return None; + } + let mut preview = Vec::new(); + for edge in 0..self.points.len().saturating_sub(1) { + if !self.invisible[edge] { + append_segment(&mut preview, self.points[edge], self.points[edge + 1]); + } + } + let pending_edge = self.points.len() - 1; + if !self.invisible[pending_edge] { + append_segment(&mut preview, *self.points.last().unwrap(), cursor); + } + if self.points.len() == 3 && !(self.invisible[3] || self.pending_invisible) { + append_segment(&mut preview, cursor, self.points[0]); + } + Some(WireModel::solid_f64( + "face3d_preview".to_string(), + preview, + WireModel::CYAN, + false, + )) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum EdgeMode { + Toggle, + Display, + DisplaySelect, +} + +pub struct FaceEdgeCommand { + faces: Vec<(Handle, Face3D)>, + mode: EdgeMode, + show_all_hidden: bool, + displayed_faces: HashSet, +} + +impl FaceEdgeCommand { + pub fn new(faces: Vec<(Handle, Face3D)>) -> Self { + Self { + faces, + mode: EdgeMode::Toggle, + show_all_hidden: false, + displayed_faces: HashSet::new(), + } + } + + fn corners(face: &Face3D) -> [DVec3; 4] { + [ + point(face.first_corner), + point(face.second_corner), + point(face.third_corner), + point(face.fourth_corner), + ] + } + + fn nearest_edge(face: &Face3D, picked: DVec3) -> Option { + let corners = Self::corners(face); + let mut best = None; + for edge in 0..4 { + let start = corners[edge]; + let end = corners[(edge + 1) % 4]; + let segment = end - start; + let length_squared = segment.length_squared(); + if length_squared <= EPSILON * EPSILON { + continue; + } + let parameter = ((picked - start).dot(segment) / length_squared).clamp(0.0, 1.0); + let distance_squared = (picked - (start + segment * parameter)).length_squared(); + if best.is_none_or(|(_, distance)| distance_squared < distance) { + best = Some((edge, distance_squared)); + } + } + best.map(|(edge, _)| edge) + } + + fn edge_wire(face: &Face3D, edge: usize, name: &str) -> WireModel { + let corners = Self::corners(face); + WireModel::solid_f64( + name.to_string(), + vec![corners[edge].to_array(), corners[(edge + 1) % 4].to_array()], + WireModel::CYAN, + false, + ) + } + + fn edges_overlap_collinearly( + selected_start: DVec3, + selected_end: DVec3, + candidate_start: DVec3, + candidate_end: DVec3, + ) -> bool { + let selected = selected_end - selected_start; + let selected_length = selected.length(); + let candidate = candidate_end - candidate_start; + if selected_length <= EPSILON || candidate.length() <= EPSILON { + return false; + } + let direction = selected / selected_length; + let scale = selected_length + .max(candidate.length()) + .max(1.0); + let tolerance = EPSILON * scale; + let line_distance = |point: DVec3| { + let offset = point - selected_start; + (offset - direction * offset.dot(direction)).length() + }; + if line_distance(candidate_start) > tolerance + || line_distance(candidate_end) > tolerance + { + return false; + } + let t0 = (candidate_start - selected_start).dot(direction); + let t1 = (candidate_end - selected_start).dot(direction); + let candidate_min = t0.min(t1); + let candidate_max = t0.max(t1); + candidate_max.min(selected_length) - candidate_min.max(0.0) > tolerance + } + + fn collinear_replacements( + &mut self, + selected_face: usize, + selected_edge: usize, + ) -> Vec<(Handle, Vec)> { + let selected_corners = Self::corners(&self.faces[selected_face].1); + let selected_start = selected_corners[selected_edge]; + let selected_end = selected_corners[(selected_edge + 1) % 4]; + let make_invisible = !edge_is_invisible( + self.faces[selected_face].1.invisible_edges, + selected_edge, + ); + let mut replacements = Vec::new(); + for (handle, face) in &mut self.faces { + let corners = Self::corners(face); + let mut updated = face.clone(); + let mut changed = false; + for edge in 0..4 { + if Self::edges_overlap_collinearly( + selected_start, + selected_end, + corners[edge], + corners[(edge + 1) % 4], + ) && edge_is_invisible(updated.invisible_edges, edge) != make_invisible + { + set_edge_invisible(&mut updated.invisible_edges, edge, make_invisible); + changed = true; + } + } + if changed { + *face = updated.clone(); + replacements.push((*handle, vec![EntityType::Face3D(updated)])); + } + } + replacements + } + + fn hidden_edges_wire(&self) -> WireModel { + let mut points = Vec::new(); + for (handle, face) in &self.faces { + if !self.show_all_hidden && !self.displayed_faces.contains(handle) { + continue; + } + let corners = Self::corners(face); + for edge in 0..4 { + if edge_is_invisible(face.invisible_edges, edge) + && (corners[(edge + 1) % 4] - corners[edge]).length_squared() + > EPSILON * EPSILON + { + append_segment(&mut points, corners[edge], corners[(edge + 1) % 4]); + } + } + } + WireModel::solid_f64( + "face3d_hidden_edges".to_string(), + points, + WireModel::CYAN, + false, + ) + } +} + +impl CadCommand for FaceEdgeCommand { + fn name(&self) -> &'static str { + "EDGE" + } + + fn prompt(&self) -> String { + match self.mode { + EdgeMode::Toggle => { + t!("EDGE Select edge of 3D face to toggle visibility or [Display]:").into_owned() + } + EdgeMode::Display => { + t!("EDGE Display invisible edges [All/Select]