feat(area): support multi-object selection

This commit is contained in:
Hakan Seven 2026-08-04 00:01:12 +03:00
commit 5662e07303
8 changed files with 426 additions and 99 deletions

View file

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

View file

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

View file

@ -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(&regions);
}
}
/// 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<CmdResult> = {
@ -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,

View file

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

View file

@ -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<f64>,
}
#[derive(Clone)]
pub enum AreaPreviewSource {
Handles(Vec<Handle>),
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<SelectionEntity>) {}
fn area_preview_regions(&self) -> Option<Vec<AreaPreviewRegion>> {
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

View file

@ -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<DVec3>,
object_pick: bool,
picked_entity: Option<EntityType>,
picked_surface_area: Option<f64>,
objects_gathering: bool,
selected_entities: Vec<SelectionEntity>,
preview_regions: Vec<AreaPreviewRegion>,
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<AreaMeasurement> {
Self::entity_measurement(&entity.entity).or_else(|| {
entity.surface_area.map(|area| AreaMeasurement {
area,
perimeter: None,
})
})
}
fn selection_measurement(&self) -> Option<(AreaMeasurement, Vec<Handle>)> {
let measured = self
.selected_entities
.iter()
.filter_map(|entity| {
Self::selection_entity_measurement(entity)
.map(|measurement| (entity.handle, measurement))
})
.collect::<Vec<_>>();
if measured.is_empty() {
return None;
}
let area = measured.iter().map(|(_, measurement)| measurement.area).sum();
let perimeter = measured
.iter()
.map(|(_, measurement)| measurement.perimeter)
.collect::<Option<Vec<_>>>()
.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<CmdOption> {
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<CmdResult> {
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<SelectionEntity>) {
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<Handle>) -> 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<WireModel> {
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<Vec<AreaPreviewRegion>> {
let mut regions = self.preview_regions.clone();
if self.objects_gathering {
let handles = self
.selected_entities
.iter()
.map(|entity| entity.handle)
.collect::<Vec<_>>();
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"] });

View file

@ -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 &region.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<HatchModel>,
) {
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<Vec<[f64; 2]>>, 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::<f64>()
.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<HatchModel> {
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<HatchModel>) {
let Some(mut model) = self.hatches.get(&handle).cloned() else {
return;

View file

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