feat(area): add object and aggregate modes

Mesh-backed measurements need cached surface metrics. Keep shared loop collection available in web builds.
This commit is contained in:
Hakan Seven 2026-08-03 22:31:20 +03:00
commit 48332ee807
8 changed files with 458 additions and 62 deletions

View file

@ -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] <Object>:
area-prompt-add = AREA ADD Specify first corner point or [Object/Subtract area] <Object>:
area-prompt-subtract = AREA SUBTRACT Specify first corner point or [Object/Add area] <Object>:
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

View file

@ -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] <Nesne>:
area-prompt-add = AREA EKLE İlk köşe noktasını belirtin veya [Nesne/Alan çıkar] <Nesne>:
area-prompt-subtract = AREA ÇIKAR İlk köşe noktasını belirtin veya [Nesne/Alan ekle] <Nesne>:
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

View file

@ -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,

View file

@ -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);
}
}
}
}

View file

@ -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

View file

@ -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::<Vec<_>>();
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::<Vec<_>>();
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();

View file

@ -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<f64>,
}
pub struct AreaCommand {
mode: AreaMode,
points: Vec<DVec3>,
object_pick: bool,
picked_entity: Option<EntityType>,
picked_surface_area: Option<f64>,
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::<f64>();
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<AreaMeasurement> {
match entity {
EntityType::LwPolyline(polyline) => {
let points = polyline
.vertices
.iter()
.map(|vertex| (vertex.location.x, vertex.location.y))
.collect::<Vec<_>>();
let bulges = polyline.vertices.iter().map(|vertex| vertex.bulge).collect::<Vec<_>>();
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::<Vec<_>>();
let bulges = polyline.vertices.iter().map(|vertex| vertex.bulge).collect::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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<CmdOption> {
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<CmdResult> {
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<WireModel> {
if self.points.is_empty() {
fn on_mouse_move(&mut self, point: DVec3) -> Option<WireModel> {
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::<Vec<_>>();
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"] });

View file

@ -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,