diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 05eb25329..c28bb7399 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -40,15 +40,16 @@ 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-objects = Objects +area-option-back = Back 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-first = AREA Specify first corner point or [Objects/Add area/Subtract area] : +area-prompt-add = AREA ADD Specify first corner point or [Objects/Subtract area] : +area-prompt-subtract = AREA SUBTRACT Specify first corner point or [Objects/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-prompt-objects = AREA Select objects ({ $count } selected, Enter to calculate): +area-objects-not-measurable = AREA: none of the selected objects has a measurable area. area-result = Area = { $area }, Perimeter = { $perimeter } area-result-area-only = Area = { $area } area-running-result = diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 1ffa90042..92ff06d0a 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -40,15 +40,16 @@ 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-objects = Nesneler +area-option-back = Geri 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-first = AREA İlk köşe noktasını belirtin veya [Nesneler/Alan ekle/Alan çıkar] : +area-prompt-add = AREA EKLE İlk köşe noktasını belirtin veya [Nesneler/Alan çıkar] : +area-prompt-subtract = AREA ÇIKAR İlk köşe noktasını belirtin veya [Nesneler/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-prompt-objects = AREA Nesneleri seçin ({ $count } seçildi, hesaplamak için Enter): +area-objects-not-measurable = AREA: Seçilen nesnelerin hiçbirinde ölçülebilir alan yok. area-result = Alan = { $area }, Çevre = { $perimeter } area-result-area-only = Alan = { $area } area-running-result = diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 1341f0ca7..69b172f9a 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1,9 +1,19 @@ use super::{Message, OpenCADStudio}; -use crate::command::{CmdResult, StepInput}; +use crate::command::{CmdResult, SelectionEntity, StepInput}; use acadrust::Handle; use iced::Task; impl OpenCADStudio { + fn refresh_area_preview(&mut self, i: usize) { + let regions = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|command| command.area_preview_regions()); + if let Some(regions) = regions { + self.tabs[i].scene.set_area_preview_regions(®ions); + } + } + /// Point supplied by a bare Enter before LINE/PLINE's first click. Prefer /// the current endpoint of the most recently created path drawable in the /// active space. A loaded drawing has no runtime anchor, so recover its @@ -258,6 +268,31 @@ impl OpenCADStudio { self.reset_tracking_after_point(); self.push_ucs_to_cmd(i); } + if let StepInput::SelectionComplete(handles) = &input { + let entities = { + let scene = &self.tabs[i].scene; + handles + .iter() + .filter_map(|handle| { + scene.document.get_entity(*handle).cloned().map(|entity| { + let surface_area = scene + .meshes + .get(handle) + .or_else(|| scene.block_meshes.get(handle)) + .map(|mesh| mesh.metrics.surface_area); + SelectionEntity { + handle: *handle, + entity, + surface_area, + } + }) + }) + .collect() + }; + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.inject_selection_entities(entities); + } + } let ctrl = self.ctrl_down; let shift = self.shift_down; let result: Option = { @@ -399,14 +434,7 @@ impl OpenCADStudio { .into_iter() .map(|(h, _)| h) .collect(); - let result = self.tabs[i] - .active_cmd - .as_mut() - .map(|cmd| cmd.on_selection_complete(handles)); - Some(match result { - Some(r) => self.apply_cmd_result(r), - None => Task::none(), - }) + Some(self.feed_command(StepInput::SelectionComplete(handles))) } pub(super) fn feed_active_cmd(&mut self, token: &str) { @@ -557,6 +585,7 @@ impl OpenCADStudio { // and typed digits land in it rather than the command line, // instead of waiting for the next cursor move to resync. self.sync_dyn_fields(); + self.refresh_area_preview(i); } CmdResult::Preview(wire) => { self.tabs[i].scene.set_preview_wires(vec![wire]); @@ -1792,11 +1821,33 @@ impl OpenCADStudio { CmdResult::ReportMeasurement(msg) => { self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); + self.refresh_area_preview(i); 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::ReportMeasurementAndDeselect(msg) => { + self.tabs[i].snap_result = None; + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.clear_preview_wire(); + self.refresh_area_preview(i); + self.refresh_properties(); + 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::DeselectAndContinue => { + self.tabs[i].snap_result = None; + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.clear_preview_wire(); + self.refresh_area_preview(i); + self.refresh_properties(); + 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 78cf303bb..02360d89f 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2417,6 +2417,11 @@ impl OpenCADStudio { .as_ref() .map(|c| c.is_selection_gathering()) .unwrap_or(false); + let selection_pick_add = self.pick_add + || self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.selection_forces_add()); // A committed window corner must stay free of the Ortho/Polar // lock too, so the picked rectangle isn't flattened (#291). let is_window_corner = self.tabs[i] @@ -3011,7 +3016,7 @@ impl OpenCADStudio { self.tabs[i].scene.deselect_entity(*h); } } else { - if !self.pick_add && !handles.is_empty() { + if !selection_pick_add && !handles.is_empty() { self.tabs[i].scene.deselect_all(); } for h in &handles { @@ -3142,7 +3147,7 @@ impl OpenCADStudio { // PICKADD 0 (#226): a plain marquee REPLACES // the selection (empty results still leave it // alone, matching the box rule). - if !self.pick_add && !handles.is_empty() { + if !selection_pick_add && !handles.is_empty() { self.tabs[i].scene.deselect_all(); } for h in &handles { @@ -3271,7 +3276,7 @@ impl OpenCADStudio { // REPLACES the selection instead and // Shift+click toggles membership. if self.shift_down { - if !self.pick_add + if !selection_pick_add && !self.tabs[i].scene.selected.contains(&handle) { self.tabs[i].scene.select_entity(handle, false); @@ -3280,7 +3285,9 @@ impl OpenCADStudio { self.tabs[i].scene.deselect_entity(handle); } } else { - self.tabs[i].scene.select_entity(handle, !self.pick_add); + self.tabs[i] + .scene + .select_entity(handle, !selection_pick_add); self.tabs[i].scene.expand_selection_for_groups(&[handle]); } self.refresh_properties(); @@ -3294,7 +3301,7 @@ impl OpenCADStudio { // selection. // PICKADD 0 (#226): OS convention — the // empty click also drops the selection. - if !self.pick_add && !self.shift_down { + if !selection_pick_add && !self.shift_down { self.tabs[i].scene.deselect_all(); self.refresh_properties(); } @@ -3413,7 +3420,7 @@ impl OpenCADStudio { self.tabs[i].scene.deselect_entity(*h); } } else { - if !self.pick_add && !handles.is_empty() { + if !selection_pick_add && !handles.is_empty() { self.tabs[i].scene.deselect_all(); } for h in &handles { @@ -3447,10 +3454,7 @@ impl OpenCADStudio { .into_iter() .map(|(h, _)| h) .collect(); - if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { - let result = cmd.on_selection_complete(handles); - return self.apply_cmd_result(result); - } + return self.feed_command(crate::command::StepInput::SelectionComplete(handles)); } // ── Double-click in Model Space: DDEDIT for Text/MText ──── diff --git a/src/command.rs b/src/command.rs index 2f56c90b3..c6f2ede23 100644 --- a/src/command.rs +++ b/src/command.rs @@ -20,6 +20,25 @@ pub struct ObjectPickHit { pub label: &'static str, } +#[derive(Clone)] +pub struct SelectionEntity { + pub handle: Handle, + pub entity: EntityType, + pub surface_area: Option, +} + +#[derive(Clone)] +pub enum AreaPreviewSource { + Handles(Vec), + Boundary(Vec<[f64; 2]>), +} + +#[derive(Clone)] +pub struct AreaPreviewRegion { + pub source: AreaPreviewSource, + pub subtract: bool, +} + // ── Transform ───────────────────────────────────────────────────────────── /// A geometric transformation applied to existing entities. @@ -908,6 +927,10 @@ pub enum CmdResult { Measurement(String), /// Print a measurement result and keep the command active. ReportMeasurement(String), + /// Print a measurement result, clear the current selection, and keep the command active. + ReportMeasurementAndDeselect(String), + /// Clear the current selection and keep the command active at its updated step. + DeselectAndContinue, /// 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. @@ -1473,6 +1496,10 @@ pub trait CadCommand: Send { false } + fn selection_forces_add(&self) -> bool { + false + } + /// Called after a selection action completes while `is_selection_gathering` is true. /// `handles` is the full set of currently selected entities. /// Return `Relaunch` to fire the pending command, or `NeedPoint` to keep gathering. @@ -1480,6 +1507,12 @@ pub trait CadCommand: Send { CmdResult::Cancel } + fn inject_selection_entities(&mut self, _entities: Vec) {} + + fn area_preview_regions(&self) -> Option> { + None + } + /// Returns `true` when the current step picks a corner of a selection /// *window* by point (e.g. STRETCH's crossing window). Such a pick must be a /// free point: applying the Ortho/Polar lock would pin the opposite corner to diff --git a/src/modules/draw/inquiry/area.rs b/src/modules/draw/inquiry/area.rs index 851537b89..997549207 100644 --- a/src/modules/draw/inquiry/area.rs +++ b/src/modules/draw/inquiry/area.rs @@ -1,7 +1,9 @@ use acadrust::{EntityType, Handle}; use glam::DVec3; -use crate::command::{CadCommand, CmdOption, CmdResult}; +use crate::command::{ + AreaPreviewRegion, AreaPreviewSource, CadCommand, CmdOption, CmdResult, SelectionEntity, +}; use crate::entities::traits::EntityTypeOps; use crate::scene::model::wire_model::WireModel; @@ -21,9 +23,9 @@ struct AreaMeasurement { pub struct AreaCommand { mode: AreaMode, points: Vec, - object_pick: bool, - picked_entity: Option, - picked_surface_area: Option, + objects_gathering: bool, + selected_entities: Vec, + preview_regions: Vec, total_area: f64, total_perimeter: f64, } @@ -33,9 +35,9 @@ impl AreaCommand { Self { mode: AreaMode::Single, points: Vec::new(), - object_pick: false, - picked_entity: None, - picked_surface_area: None, + objects_gathering: false, + selected_entities: Vec::new(), + preview_regions: Vec::new(), total_area: 0.0, total_perimeter: 0.0, } @@ -176,6 +178,38 @@ impl AreaCommand { } } + fn selection_entity_measurement(entity: &SelectionEntity) -> Option { + Self::entity_measurement(&entity.entity).or_else(|| { + entity.surface_area.map(|area| AreaMeasurement { + area, + perimeter: None, + }) + }) + } + + fn selection_measurement(&self) -> Option<(AreaMeasurement, Vec)> { + let measured = self + .selected_entities + .iter() + .filter_map(|entity| { + Self::selection_entity_measurement(entity) + .map(|measurement| (entity.handle, measurement)) + }) + .collect::>(); + if measured.is_empty() { + return None; + } + + let area = measured.iter().map(|(_, measurement)| measurement.area).sum(); + let perimeter = measured + .iter() + .map(|(_, measurement)| measurement.perimeter) + .collect::>>() + .map(|values| values.into_iter().sum()); + let handles = measured.into_iter().map(|(handle, _)| handle).collect(); + Some((AreaMeasurement { area, perimeter }, handles)) + } + fn result_message(measurement: AreaMeasurement) -> String { match measurement.perimeter { Some(perimeter) => crate::tr!( @@ -204,7 +238,11 @@ impl AreaCommand { } } - fn finish_measurement(&mut self, measurement: AreaMeasurement) -> CmdResult { + fn finish_measurement( + &mut self, + measurement: AreaMeasurement, + deselect: bool, + ) -> CmdResult { if self.mode == AreaMode::Single { return CmdResult::Measurement(Self::result_message(measurement)); } @@ -215,8 +253,14 @@ impl AreaCommand { self.total_perimeter += sign * perimeter; } self.points.clear(); - self.object_pick = false; - CmdResult::ReportMeasurement(self.running_result_message(measurement)) + self.objects_gathering = false; + self.selected_entities.clear(); + let message = self.running_result_message(measurement); + if deselect { + CmdResult::ReportMeasurementAndDeselect(message) + } else { + CmdResult::ReportMeasurement(message) + } } } @@ -226,8 +270,11 @@ impl CadCommand for AreaCommand { } fn prompt(&self) -> String { - if self.object_pick { - return crate::tr!("area-prompt-object"); + if self.objects_gathering { + return crate::tr!( + "area-prompt-objects", + count = self.selected_entities.len() + ); } if !self.points.is_empty() { return crate::tr!("area-prompt-next", count = self.points.len()); @@ -240,33 +287,54 @@ impl CadCommand for AreaCommand { } fn options(&self) -> Vec { - if self.object_pick || !self.points.is_empty() { + if self.objects_gathering { + return vec![Self::option(crate::tr!("area-option-back"), "BACK")]; + } + if !self.points.is_empty() { return Vec::new(); } - let object = || Self::option(crate::tr!("area-option-object"), "OBJECT"); + let objects = || Self::option(crate::tr!("area-option-objects"), "OBJECTS"); 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()], + AreaMode::Single => vec![objects(), add(), subtract()], + AreaMode::Add => vec![objects(), subtract()], + AreaMode::Subtract => vec![objects(), add()], } } fn wants_text_input(&self) -> bool { - !self.object_pick + !self.objects_gathering } fn point_step_accepts_keywords(&self) -> bool { - !self.object_pick + !self.objects_gathering } fn on_text_input(&mut self, text: &str) -> Option { - if self.object_pick || !self.points.is_empty() { + let input = text.trim().to_ascii_uppercase(); + if self.objects_gathering { + if matches!(input.as_str(), "B" | "BACK") { + if let Some((measurement, handles)) = self.selection_measurement() { + if self.mode != AreaMode::Single { + self.preview_regions.push(AreaPreviewRegion { + source: AreaPreviewSource::Handles(handles), + subtract: self.mode == AreaMode::Subtract, + }); + } + return Some(self.finish_measurement(measurement, true)); + } + self.objects_gathering = false; + self.selected_entities.clear(); + return Some(CmdResult::DeselectAndContinue); + } return Some(CmdResult::NeedPoint); } - match text.trim().to_ascii_uppercase().as_str() { - "O" | "OBJECT" => self.object_pick = true, + if !self.points.is_empty() { + return Some(CmdResult::NeedPoint); + } + match input.as_str() { + "O" | "OBJECT" | "OBJECTS" => self.objects_gathering = true, "A" | "ADD" => self.mode = AreaMode::Add, "S" | "SUBTRACT" => self.mode = AreaMode::Subtract, _ => return Some(CmdResult::NeedPoint), @@ -274,73 +342,67 @@ impl CadCommand for AreaCommand { Some(CmdResult::NeedPoint) } - fn needs_entity_pick(&self) -> bool { - self.object_pick + fn is_selection_gathering(&self) -> bool { + self.objects_gathering } - fn entity_pick_includes_fills(&self) -> bool { - true + fn selection_forces_add(&self) -> bool { + self.objects_gathering } - fn entity_pick_highlights_hover(&self) -> bool { - true + fn inject_selection_entities(&mut self, entities: Vec) { + self.selected_entities = entities; } - 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_selection_complete(&mut self, _handles: Vec) -> CmdResult { + CmdResult::NeedPoint } fn on_point(&mut self, point: DVec3) -> CmdResult { + if self.objects_gathering { + return CmdResult::NeedPoint; + } self.points.push(point); CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { - if self.object_pick { - return CmdResult::Cancel; + if self.objects_gathering { + let Some((measurement, handles)) = self.selection_measurement() else { + self.selected_entities.clear(); + return CmdResult::ReportMeasurementAndDeselect(crate::tr!( + "area-objects-not-measurable" + )); + }; + if self.mode != AreaMode::Single { + self.preview_regions.push(AreaPreviewRegion { + source: AreaPreviewSource::Handles(handles), + subtract: self.mode == AreaMode::Subtract, + }); + } + return self.finish_measurement(measurement, true); } if self.points.is_empty() { - self.object_pick = true; + self.objects_gathering = true; return CmdResult::NeedPoint; } if self.points.len() < 3 { return CmdResult::Cancel; } let measurement = Self::point_measurement(&self.points, true); - self.finish_measurement(measurement) + if self.mode != AreaMode::Single { + self.preview_regions.push(AreaPreviewRegion { + source: AreaPreviewSource::Boundary( + self.points.iter().map(|point| [point.x, point.y]).collect(), + ), + subtract: self.mode == AreaMode::Subtract, + }); + } + self.finish_measurement(measurement, false) } fn on_mouse_move(&mut self, point: DVec3) -> Option { - if self.object_pick || self.points.is_empty() { + if self.objects_gathering || self.points.is_empty() { return None; } let to_render = |p: DVec3| [p.x as f32, p.y as f32, p.z as f32]; @@ -375,6 +437,31 @@ impl CadCommand for AreaCommand { fill_tris_low: Vec::new(), }) } + + fn area_preview_regions(&self) -> Option> { + let mut regions = self.preview_regions.clone(); + if self.objects_gathering { + let handles = self + .selected_entities + .iter() + .map(|entity| entity.handle) + .collect::>(); + if !handles.is_empty() { + regions.push(AreaPreviewRegion { + source: AreaPreviewSource::Handles(handles), + subtract: self.mode == AreaMode::Subtract, + }); + } + } else if self.points.len() >= 3 { + regions.push(AreaPreviewRegion { + source: AreaPreviewSource::Boundary( + self.points.iter().map(|point| [point.x, point.y]).collect(), + ), + subtract: self.mode == AreaMode::Subtract, + }); + } + Some(regions) + } } inventory::submit!(crate::command::CommandRegistration { names: &["AREA"] }); diff --git a/src/scene/preview.rs b/src/scene/preview.rs index 740ad3b0b..821e84993 100644 --- a/src/scene/preview.rs +++ b/src/scene/preview.rs @@ -1,5 +1,7 @@ // Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged. use super::*; +use crate::command::{AreaPreviewRegion, AreaPreviewSource}; +use crate::scene::model::hatch_model::HatchPattern; impl Scene { // ── Preview wire ────────────────────────────────────────────────────── @@ -27,6 +29,158 @@ impl Scene { self.preview_hatches = std::sync::Arc::new(models); } + pub fn set_area_preview_regions(&mut self, regions: &[AreaPreviewRegion]) { + let mut models = Vec::new(); + for region in regions { + match ®ion.source { + AreaPreviewSource::Handles(handles) => { + for &handle in handles { + self.append_area_preview_handle(handle, region.subtract, &mut models); + } + } + AreaPreviewSource::Boundary(boundary) => { + if let Some(model) = Self::area_preview_hatch( + std::slice::from_ref(boundary), + region.subtract, + ) { + models.push(model); + } + } + } + } + self.preview_hatches = std::sync::Arc::new(models); + } + + fn append_area_preview_handle( + &self, + handle: Handle, + subtract: bool, + models: &mut Vec, + ) { + if let Some(mut model) = self.hatches.get(&handle).cloned() { + Self::style_area_preview_hatch(&mut model, subtract); + models.push(model); + return; + } + + let direct_boundary = crate::scene::project::clip_boundary_polygon_for_document( + &self.document, + handle, + 0.0, + ); + let mut rings = if direct_boundary.len() >= 3 { + vec![direct_boundary + .into_iter() + .map(|point| [point[0] as f64, point[1] as f64]) + .collect()] + } else { + Vec::new() + }; + if !rings.is_empty() { + if let Some(model) = Self::area_preview_hatch(&rings, subtract) { + models.push(model); + } + return; + } + for wire in self.wire_models_for(&[handle]) { + let mut ring = Vec::new(); + for (index, point) in wire.points.iter().enumerate() { + let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); + let x = point[0] as f64 + low[0] as f64; + let y = point[1] as f64 + low[1] as f64; + if x.is_finite() && y.is_finite() { + let candidate = [x, y]; + if ring.last().is_none_or(|last| *last != candidate) { + ring.push(candidate); + } + } else { + Self::push_area_preview_ring(&mut rings, &mut ring); + } + } + Self::push_area_preview_ring(&mut rings, &mut ring); + } + if let Some(model) = Self::area_preview_hatch(&rings, subtract) { + models.push(model); + } + } + + fn push_area_preview_ring(rings: &mut Vec>, ring: &mut Vec<[f64; 2]>) { + if ring.len() >= 3 { + let min_x = ring.iter().map(|point| point[0]).fold(f64::INFINITY, f64::min); + let max_x = ring + .iter() + .map(|point| point[0]) + .fold(f64::NEG_INFINITY, f64::max); + let min_y = ring.iter().map(|point| point[1]).fold(f64::INFINITY, f64::min); + let max_y = ring + .iter() + .map(|point| point[1]) + .fold(f64::NEG_INFINITY, f64::max); + let diagonal_sq = (max_x - min_x).powi(2) + (max_y - min_y).powi(2); + let twice_area = ring + .iter() + .zip(ring.iter().cycle().skip(1)) + .take(ring.len()) + .map(|(a, b)| a[0] * b[1] - b[0] * a[1]) + .sum::() + .abs(); + if twice_area > diagonal_sq * 1e-12 { + rings.push(std::mem::take(ring)); + return; + } + } + ring.clear(); + } + + fn area_preview_hatch(rings: &[Vec<[f64; 2]>], subtract: bool) -> Option { + let origin = rings.iter().find_map(|ring| ring.first()).copied()?; + let mut boundary = Vec::new(); + let mut first = true; + for ring in rings.iter().filter(|ring| ring.len() >= 3) { + if !first { + boundary.push([f32::NAN, f32::NAN]); + } + first = false; + boundary.extend( + ring.iter() + .map(|point| [(point[0] - origin[0]) as f32, (point[1] - origin[1]) as f32]), + ); + } + if boundary.len() < 3 { + return None; + } + let mut model = HatchModel { + world_origin: origin, + boundary: std::sync::Arc::new(boundary), + boundary_wcs: None, + pattern: HatchPattern::Solid, + name: "AREA_PREVIEW".into(), + color: [0.0; 4], + aci: 0, + line_weight_px: 1.0, + angle_offset: 0.0, + scale: 1.0, + draw_depth: 0.0, + }; + Self::style_area_preview_hatch(&mut model, subtract); + Some(model) + } + + fn style_area_preview_hatch(model: &mut HatchModel, subtract: bool) { + model.pattern = HatchPattern::Solid; + model.name = "AREA_PREVIEW".into(); + model.color = if subtract { + [1.0, 0.28, 0.18, 0.16] + } else { + [0.15, 0.55, 1.0, 0.12] + }; + model.aci = 0; + model.line_weight_px = 1.0; + model.angle_offset = 0.0; + model.scale = 1.0; + model.draw_depth = 0.0; + } + fn append_preview_hatch(&self, handle: Handle, models: &mut Vec) { let Some(mut model) = self.hatches.get(&handle).cloned() else { return; diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 0e52bcdf9..68dd01a6f 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -384,11 +384,7 @@ impl shader::Primitive for Primitive { inner.upload_preview_hatches( device, queue, - if fill_mode { - &vp.preview_hatches[..] - } else { - &[] - }, + &vp.preview_hatches[..], ); inner.cached_preview_hatch_source = Some(Arc::clone(&vp.preview_hatches));