From 48332ee8078909cabf3f4b837bc2e322b8d9bd14 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 3 Aug 2026 22:31:20 +0300 Subject: [PATCH] feat(area): add object and aggregate modes Mesh-backed measurements need cached surface metrics. Keep shared loop collection available in web builds. --- locales/en-US/opencadstudio.ftl | 18 ++ locales/tr-TR/opencadstudio.ftl | 18 ++ src/app/command_driver.rs | 8 + src/app/update/viewport.rs | 9 + src/command.rs | 7 + src/entities/spline.rs | 78 ++++++- src/modules/draw/inquiry/area.rs | 377 +++++++++++++++++++++++++----- src/scene/convert/solid3d_tess.rs | 1 - 8 files changed, 456 insertions(+), 60 deletions(-) diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index f4232a9ef..05eb25329 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -40,6 +40,24 @@ command-line-hint = Type a command or use the ribbon. Open OBJ from the Insert t command-line-label = Command: command-line-literal-spaces = Literal spaces: Space stays in the line instead of running the command. Stays on until toggled off. +area-option-object = Object +area-option-add = Add area +area-option-subtract = Subtract area +area-prompt-first = AREA Specify first corner point or [Object/Add area/Subtract area] : +area-prompt-add = AREA ADD Specify first corner point or [Object/Subtract area] : +area-prompt-subtract = AREA SUBTRACT Specify first corner point or [Object/Add area] : +area-prompt-next = AREA Specify next point ({ $count } picked, Enter to calculate): +area-prompt-object = AREA Select object: +area-object-not-measurable = AREA: the selected object has no measurable area. +area-result = Area = { $area }, Perimeter = { $perimeter } +area-result-area-only = Area = { $area } +area-running-result = + Area = { $area }, Perimeter = { $perimeter } + Total area = { $total_area }, Total perimeter = { $total_perimeter } +area-running-result-area-only = + Area = { $area } + Total area = { $total_area } + start-new-drawing = New Drawing start-open-file = Open File… start-donate = Donate diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index fa9d114e4..1ffa90042 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -40,6 +40,24 @@ command-line-hint = Bir komut yazın veya şeridi kullanın. OBJ dosyasını Ekl command-line-label = Komut: command-line-literal-spaces = Değişmez boşluklar: Boşluk, komutu çalıştırmak yerine satırda kalır. Yeniden kapatılana kadar etkin kalır. +area-option-object = Nesne +area-option-add = Alan ekle +area-option-subtract = Alan çıkar +area-prompt-first = AREA İlk köşe noktasını belirtin veya [Nesne/Alan ekle/Alan çıkar] : +area-prompt-add = AREA EKLE İlk köşe noktasını belirtin veya [Nesne/Alan çıkar] : +area-prompt-subtract = AREA ÇIKAR İlk köşe noktasını belirtin veya [Nesne/Alan ekle] : +area-prompt-next = AREA Sonraki noktayı belirtin ({ $count } seçildi, hesaplamak için Enter): +area-prompt-object = AREA Nesne seçin: +area-object-not-measurable = AREA: Seçilen nesnenin ölçülebilir alanı yok. +area-result = Alan = { $area }, Çevre = { $perimeter } +area-result-area-only = Alan = { $area } +area-running-result = + Alan = { $area }, Çevre = { $perimeter } + Toplam alan = { $total_area }, Toplam çevre = { $total_perimeter } +area-running-result-area-only = + Alan = { $area } + Toplam alan = { $total_area } + start-new-drawing = Yeni Çizim start-open-file = Dosya Aç… start-donate = Bağış Yap diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index f50ec8dad..1341f0ca7 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1789,6 +1789,14 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); self.command_line.push_output(&msg); } + CmdResult::ReportMeasurement(msg) => { + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.command_line.push_output(&msg); + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) { + self.command_line.push_info(&prompt); + } + } CmdResult::AlignSelected { handles, src1, diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index fc7260b11..78cf303bb 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2710,11 +2710,20 @@ impl OpenCADStudio { .map(|c| c.inject_before_entity_pick()) .unwrap_or(false); if inject_first { + let surface_area = self.tabs[i] + .scene + .meshes + .get(&handle) + .or_else(|| self.tabs[i].scene.block_meshes.get(&handle)) + .map(|mesh| mesh.metrics.surface_area); if let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() { if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { cmd.inject_picked_entity(entity); + if let Some(area) = surface_area { + cmd.inject_picked_surface_area(area); + } } } } diff --git a/src/command.rs b/src/command.rs index 242194712..2f56c90b3 100644 --- a/src/command.rs +++ b/src/command.rs @@ -906,6 +906,8 @@ pub enum CmdResult { ZoomToWindow { p1: DVec3, p2: DVec3 }, /// Print a measurement result to the command line and end the command. Measurement(String), + /// Print a measurement result and keep the command active. + ReportMeasurement(String), /// Break `handle` at points `p1` and `p2`; replace with computed fragments. BreakEntity { handle: Handle, p1: DVec3, p2: DVec3 }, /// Attempt to join the given entities into fewer merged entities. @@ -1567,6 +1569,11 @@ pub trait CadCommand: Send { /// Default: no-op. fn inject_picked_entity(&mut self, _entity: acadrust::EntityType) {} + /// Supply the tessellated surface area associated with the picked entity. + /// Commands that measure mesh-backed objects can opt in without owning the + /// scene's render cache. + fn inject_picked_surface_area(&mut self, _area: f64) {} + /// What the command is asking for at this step, used to label the /// dynamic-input overlay. Default is a point pick; commands waiting /// on a radius/length return `Distance` and angle prompts return diff --git a/src/entities/spline.rs b/src/entities/spline.rs index 8140f18fb..e644bc91f 100644 --- a/src/entities/spline.rs +++ b/src/entities/spline.rs @@ -1,7 +1,7 @@ use acadrust::entities::Spline; use crate::t; use truck_modeling::{ - base::{BoundedCurve, ParametricCurve, Vector4}, + base::{BoundedCurve, ParameterDivision1D, ParametricCurve, Vector4}, builder, BSplineCurve, Curve, Edge, KnotVec, NurbsCurve, Point3, Wire, }; @@ -127,6 +127,77 @@ fn to_truck(spl: &Spline) -> TruckEntity { } } +pub(crate) fn measurement_polyline(spl: &Spline) -> Vec<[f64; 3]> { + let count = spl.control_points.len(); + if count < 2 { + if spl.fit_points.len() < 2 { + return spl.control_points.iter().map(|p| [p.x, p.y, p.z]).collect(); + } + return if spl.flags.closed || spl.flags.periodic { + catmull_rom_polyline(&spl.fit_points, true) + } else { + fit_spline_polyline(spl) + }; + } + + let degree = spl.degree.max(0) as usize; + if degree == 0 || degree >= count { + return spl.control_points.iter().map(|p| [p.x, p.y, p.z]).collect(); + } + let knot_vec = if spl.knots.len() == count + degree + 1 { + KnotVec::from(spl.knots.clone()) + } else { + KnotVec::uniform_knot(degree, count - 1) + }; + + let mut min = [f64::INFINITY; 3]; + let mut max = [f64::NEG_INFINITY; 3]; + for point in &spl.control_points { + min[0] = min[0].min(point.x); + min[1] = min[1].min(point.y); + min[2] = min[2].min(point.z); + max[0] = max[0].max(point.x); + max[1] = max[1].max(point.y); + max[2] = max[2].max(point.z); + } + let diagonal = ((max[0] - min[0]).powi(2) + + (max[1] - min[1]).powi(2) + + (max[2] - min[2]).powi(2)) + .sqrt(); + let tolerance = crate::scene::convert::tess_util::fill_chord_tol(diagonal.max(1.0)); + + if spl.weights.len() == count { + let controls = spl + .control_points + .iter() + .zip(&spl.weights) + .map(|(point, &weight)| { + let weight = if weight.abs() < 1e-12 { 1.0 } else { weight }; + Vector4::new( + point.x * weight, + point.y * weight, + point.z * weight, + weight, + ) + }) + .collect::>(); + let curve = NurbsCurve::new(BSplineCurve::new(knot_vec, controls)); + let range = curve.range_tuple(); + let (_, points) = curve.parameter_division(range, tolerance); + points.into_iter().map(|point| [point.x, point.y, point.z]).collect() + } else { + let controls = spl + .control_points + .iter() + .map(|point| Point3::new(point.x, point.y, point.z)) + .collect::>(); + let curve = BSplineCurve::new(knot_vec, controls); + let range = curve.range_tuple(); + let (_, points) = curve.parameter_division(range, tolerance); + points.into_iter().map(|point| [point.x, point.y, point.z]).collect() + } +} + /// Sample a Catmull-Rom spline through `pts` into a dense polyline. The curve /// passes through every input point; open ends use reflected phantom points so /// they don't kink, closed curves wrap around. @@ -195,9 +266,8 @@ fn catmull_rom_polyline(pts: &[acadrust::types::Vector3], closed: bool) -> Vec<[ /// Interpolate an open fit-point spline into a dense polyline: the C² cubic that /// passes through every fit point, clamped to the stored start/end tangents when -/// present (natural end otherwise). This is what a fit spline *is* — the same -/// interpolation AutoCAD-family tools draw — so its ends follow the specified -/// tangents instead of the local slopes Catmull-Rom would use. +/// present (natural end otherwise), so its ends follow the specified tangents +/// instead of the local slopes Catmull-Rom would use. fn fit_spline_polyline(spl: &Spline) -> Vec<[f64; 3]> { let p: Vec<[f64; 3]> = spl.fit_points.iter().map(|q| [q.x, q.y, q.z]).collect(); let n = p.len(); diff --git a/src/modules/draw/inquiry/area.rs b/src/modules/draw/inquiry/area.rs index 77c95b1cd..851537b89 100644 --- a/src/modules/draw/inquiry/area.rs +++ b/src/modules/draw/inquiry/area.rs @@ -1,19 +1,222 @@ -// AREA command — compute area and perimeter of a polygon picked point by point. -// Press Enter to close and calculate. - +use acadrust::{EntityType, Handle}; use glam::DVec3; -use crate::t; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdOption, CmdResult}; +use crate::entities::traits::EntityTypeOps; use crate::scene::model::wire_model::WireModel; +#[derive(Clone, Copy, PartialEq, Eq)] +enum AreaMode { + Single, + Add, + Subtract, +} + +#[derive(Clone, Copy)] +struct AreaMeasurement { + area: f64, + perimeter: Option, +} + pub struct AreaCommand { + mode: AreaMode, points: Vec, + object_pick: bool, + picked_entity: Option, + picked_surface_area: Option, + total_area: f64, + total_perimeter: f64, } impl AreaCommand { pub fn new() -> Self { - Self { points: vec![] } + Self { + mode: AreaMode::Single, + points: Vec::new(), + object_pick: false, + picked_entity: None, + picked_surface_area: None, + total_area: 0.0, + total_perimeter: 0.0, + } + } + + fn option(label: String, keyword: &str) -> CmdOption { + CmdOption { label, keyword: keyword.to_string() } + } + + fn point_measurement(points: &[DVec3], close_perimeter: bool) -> AreaMeasurement { + if points.len() < 2 { + return AreaMeasurement { area: 0.0, perimeter: Some(0.0) }; + } + + let origin = points[0]; + let mut area_vector = DVec3::ZERO; + for index in 0..points.len() { + let a = points[index] - origin; + let b = points[(index + 1) % points.len()] - origin; + area_vector += a.cross(b); + } + + let mut perimeter = points + .windows(2) + .map(|pair| (pair[1] - pair[0]).length()) + .sum::(); + if close_perimeter { + perimeter += (points[0] - points[points.len() - 1]).length(); + } + + AreaMeasurement { area: area_vector.length() * 0.5, perimeter: Some(perimeter) } + } + + fn bulged_polyline_measurement( + points: &[(f64, f64)], + bulges: &[f64], + closed: bool, + ) -> AreaMeasurement { + let count = points.len(); + if count < 2 { + return AreaMeasurement { area: 0.0, perimeter: Some(0.0) }; + } + + let segment_count = if closed { count } else { count - 1 }; + let origin = points[0]; + let mut signed_area = 0.0; + let mut perimeter = 0.0; + for index in 0..segment_count { + let a = points[index]; + let b = points[(index + 1) % count]; + let local_a = (a.0 - origin.0, a.1 - origin.1); + let local_b = (b.0 - origin.0, b.1 - origin.1); + signed_area += 0.5 * (local_a.0 * local_b.1 - local_b.0 * local_a.1); + + let chord = (b.0 - a.0).hypot(b.1 - a.1); + let bulge = bulges.get(index).copied().unwrap_or(0.0); + if bulge.abs() < 1e-12 || chord <= 1e-12 { + perimeter += chord; + continue; + } + + let angle = 4.0 * bulge.atan(); + let sine = (angle * 0.5).sin().abs(); + if sine <= 1e-12 { + perimeter += chord; + continue; + } + let radius = chord / (2.0 * sine); + signed_area += 0.5 * radius * radius * (angle - angle.sin()); + perimeter += radius * angle.abs(); + } + + AreaMeasurement { area: signed_area.abs(), perimeter: Some(perimeter) } + } + + fn entity_measurement(entity: &EntityType) -> Option { + match entity { + EntityType::LwPolyline(polyline) => { + let points = polyline + .vertices + .iter() + .map(|vertex| (vertex.location.x, vertex.location.y)) + .collect::>(); + let bulges = polyline.vertices.iter().map(|vertex| vertex.bulge).collect::>(); + Some(Self::bulged_polyline_measurement( + &points, + &bulges, + polyline.is_closed, + )) + } + EntityType::Polyline2D(polyline) => { + let points = polyline + .vertices + .iter() + .map(|vertex| (vertex.location.x, vertex.location.y)) + .collect::>(); + let bulges = polyline.vertices.iter().map(|vertex| vertex.bulge).collect::>(); + Some(Self::bulged_polyline_measurement( + &points, + &bulges, + polyline.is_closed(), + )) + } + EntityType::Polyline(polyline) => { + let points = polyline + .vertices + .iter() + .map(|vertex| { + DVec3::new(vertex.location.x, vertex.location.y, vertex.location.z) + }) + .collect::>(); + Some(Self::point_measurement(&points, polyline.is_closed())) + } + EntityType::Polyline3D(polyline) => { + let points = polyline + .vertices + .iter() + .map(|vertex| { + DVec3::new(vertex.position.x, vertex.position.y, vertex.position.z) + }) + .collect::>(); + Some(Self::point_measurement(&points, polyline.is_closed())) + } + EntityType::Spline(spline) => { + let points = crate::entities::spline::measurement_polyline(spline) + .into_iter() + .map(|point| DVec3::new(point[0], point[1], point[2])) + .collect::>(); + Some(Self::point_measurement( + &points, + spline.flags.closed || spline.flags.periodic, + )) + } + _ => entity.mass_props().map(|props| AreaMeasurement { + area: props.area, + perimeter: Some(props.perimeter), + }), + } + } + + fn result_message(measurement: AreaMeasurement) -> String { + match measurement.perimeter { + Some(perimeter) => crate::tr!( + "area-result", + area = format!("{:.4}", measurement.area), + perimeter = format!("{perimeter:.4}"), + ), + None => crate::tr!("area-result-area-only", area = format!("{:.4}", measurement.area)), + } + } + + fn running_result_message(&self, measurement: AreaMeasurement) -> String { + match measurement.perimeter { + Some(perimeter) => crate::tr!( + "area-running-result", + area = format!("{:.4}", measurement.area), + perimeter = format!("{perimeter:.4}"), + total_area = format!("{:.4}", self.total_area), + total_perimeter = format!("{:.4}", self.total_perimeter), + ), + None => crate::tr!( + "area-running-result-area-only", + area = format!("{:.4}", measurement.area), + total_area = format!("{:.4}", self.total_area), + ), + } + } + + fn finish_measurement(&mut self, measurement: AreaMeasurement) -> CmdResult { + if self.mode == AreaMode::Single { + return CmdResult::Measurement(Self::result_message(measurement)); + } + + let sign = if self.mode == AreaMode::Add { 1.0 } else { -1.0 }; + self.total_area += sign * measurement.area; + if let Some(perimeter) = measurement.perimeter { + self.total_perimeter += sign * perimeter; + } + self.points.clear(); + self.object_pick = false; + CmdResult::ReportMeasurement(self.running_result_message(measurement)) } } @@ -23,61 +226,127 @@ impl CadCommand for AreaCommand { } fn prompt(&self) -> String { - if self.points.is_empty() { - t!("AREA Specify first corner point (Enter to cancel):").into_owned() - } else { - let n = self.points.len(); - t!( - "AREA Specify next point (%{n} picked, Enter to calculate):", - n = n - ) - .into_owned() + if self.object_pick { + return crate::tr!("area-prompt-object"); + } + if !self.points.is_empty() { + return crate::tr!("area-prompt-next", count = self.points.len()); + } + match self.mode { + AreaMode::Single => crate::tr!("area-prompt-first"), + AreaMode::Add => crate::tr!("area-prompt-add"), + AreaMode::Subtract => crate::tr!("area-prompt-subtract"), } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - self.points.push(pt); + fn options(&self) -> Vec { + if self.object_pick || !self.points.is_empty() { + return Vec::new(); + } + let object = || Self::option(crate::tr!("area-option-object"), "OBJECT"); + let add = || Self::option(crate::tr!("area-option-add"), "ADD"); + let subtract = || Self::option(crate::tr!("area-option-subtract"), "SUBTRACT"); + match self.mode { + AreaMode::Single => vec![object(), add(), subtract()], + AreaMode::Add => vec![object(), subtract()], + AreaMode::Subtract => vec![object(), add()], + } + } + + fn wants_text_input(&self) -> bool { + !self.object_pick + } + + fn point_step_accepts_keywords(&self) -> bool { + !self.object_pick + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.object_pick || !self.points.is_empty() { + return Some(CmdResult::NeedPoint); + } + match text.trim().to_ascii_uppercase().as_str() { + "O" | "OBJECT" => self.object_pick = true, + "A" | "ADD" => self.mode = AreaMode::Add, + "S" | "SUBTRACT" => self.mode = AreaMode::Subtract, + _ => return Some(CmdResult::NeedPoint), + } + Some(CmdResult::NeedPoint) + } + + fn needs_entity_pick(&self) -> bool { + self.object_pick + } + + fn entity_pick_includes_fills(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + self.picked_surface_area = None; + } + + fn inject_picked_surface_area(&mut self, area: f64) { + self.picked_surface_area = Some(area); + } + + fn on_entity_pick(&mut self, handle: Handle, _point: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + let measurement = self + .picked_entity + .take() + .and_then(|entity| Self::entity_measurement(&entity)) + .or_else(|| { + self.picked_surface_area.take().map(|area| AreaMeasurement { + area, + perimeter: None, + }) + }); + match measurement { + Some(measurement) => self.finish_measurement(measurement), + None => CmdResult::ReportMeasurement(crate::tr!("area-object-not-measurable")), + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + self.points.push(point); CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { + if self.object_pick { + return CmdResult::Cancel; + } + if self.points.is_empty() { + self.object_pick = true; + return CmdResult::NeedPoint; + } if self.points.len() < 3 { return CmdResult::Cancel; } - // Shoelace formula in the world XY plane, evaluated in f64 relative to - // the first vertex. Subtracting that origin keeps the cross-product - // terms small even at large (survey/UTM) coordinates, avoiding the - // catastrophic cancellation a raw f32/absolute evaluation suffers. - let n = self.points.len(); - let origin = self.points[0]; - let mut area_sum = 0.0f64; - let mut perimeter = 0.0f64; - for idx in 0..n { - let a = self.points[idx] - origin; - let b = self.points[(idx + 1) % n] - origin; - area_sum += a.x * b.y - b.x * a.y; - perimeter += (self.points[(idx + 1) % n] - self.points[idx]).length(); - } - let area = (area_sum * 0.5).abs(); - let area_s = format!("{area:.4}"); - let perimeter_s = format!("{perimeter:.4}"); - let msg = t!( - "Area = %{area}, Perimeter = %{perimeter}", - area = area_s, - perimeter = perimeter_s - ) - .into_owned(); - CmdResult::Measurement(msg) + let measurement = Self::point_measurement(&self.points, true); + self.finish_measurement(measurement) } - fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if self.points.is_empty() { + fn on_mouse_move(&mut self, point: DVec3) -> Option { + if self.object_pick || self.points.is_empty() { return None; } - let f = |p: DVec3| [p.x as f32, p.y as f32, p.z as f32]; - let mut pts: Vec<[f32; 3]> = self.points.iter().map(|p| f(*p)).collect(); - pts.push(f(pt)); - pts.push(f(self.points[0])); + let to_render = |p: DVec3| [p.x as f32, p.y as f32, p.z as f32]; + let mut points = self.points.iter().map(|point| to_render(*point)).collect::>(); + points.push(to_render(point)); + points.push(to_render(self.points[0])); Some(WireModel { taper_widths: Vec::new(), world_width: 0.0, @@ -89,25 +358,23 @@ impl CadCommand for AreaCommand { dash_align_end: None, text_verts: Vec::new(), name: "area_preview".into(), - points: pts, + points, points_low: Vec::new(), color: WireModel::CYAN, selected: false, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px: 1.0, - snap_pts: vec![], - tangent_geoms: vec![], + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), aci: 0, - key_vertices: vec![], + key_vertices: Vec::new(), aabb: WireModel::UNBOUNDED_AABB, plinegen: true, - fill_tris: vec![], + fill_tris: Vec::new(), fill_tris_low: Vec::new(), }) } } - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["AREA"] }); // AreaCommand +inventory::submit!(crate::command::CommandRegistration { names: &["AREA"] }); diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 947e27e50..bf15eb69b 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -1376,7 +1376,6 @@ pub(crate) fn collect_loop_polygon( /// All loops of a face: the outer boundary first, then any inner hole loops. /// Each loop is returned as an ordered 3-D polygon (≥ 3 points). -#[cfg(feature = "solid3d")] pub(crate) fn collect_face_loops( sat: &SatDocument, face: &SatFace,