forked from 1b-agent/opencadstudio-upstream
Merge main and preserve revolve history grips
This commit is contained in:
commit
b1deec753c
38 files changed
+2989
-453
No files matched your search
Generated
+2
-2
@@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
||||
[[package]]
|
||||
name = "acadrust"
|
||||
version = "0.5.1"
|
||||
source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=ad9b1a25ec0d3dc217e24d9c525709ec18484603#ad9b1a25ec0d3dc217e24d9c525709ec18484603"
|
||||
source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=393d6c0857447f9ca678bed97a612707f0a842c6#393d6c0857447f9ca678bed97a612707f0a842c6"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"anyhow",
|
||||
@@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
[[package]]
|
||||
name = "cadkernel"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=76ae7f836f5e6d7aa81cefb79330698e38adf95a#76ae7f836f5e6d7aa81cefb79330698e38adf95a"
|
||||
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=fb5a5c18f794df30e97c712fb5d4fe7c6629d1d5#fb5a5c18f794df30e97c712fb5d4fe7c6629d1d5"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cavalier_contours",
|
||||
|
||||
+3
-3
@@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] }
|
||||
rfd = "0.17"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
env_logger = "0.11"
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "ad9b1a25ec0d3dc217e24d9c525709ec18484603", features = ["serde"] }
|
||||
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "76ae7f836f5e6d7aa81cefb79330698e38adf95a", features = ["acis", "offset"] }
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "393d6c0857447f9ca678bed97a612707f0a842c6", features = ["serde"] }
|
||||
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "fb5a5c18f794df30e97c712fb5d4fe7c6629d1d5", features = ["acis", "offset"] }
|
||||
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
|
||||
flate2 = "1"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
|
||||
@@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa
|
||||
iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
|
||||
|
||||
[patch."https://github.com/HakanSeven12/cadcodec.git"]
|
||||
acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "ad9b1a25ec0d3dc217e24d9c525709ec18484603" }
|
||||
acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "393d6c0857447f9ca678bed97a612707f0a842c6" }
|
||||
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
|
||||
+318
-28
@@ -750,6 +750,88 @@ impl OpenCADStudio {
|
||||
task
|
||||
}
|
||||
|
||||
/// Resolve the cell under `click` on table `handle` (or any table in the drawing
|
||||
/// if `handle` is `Handle::NULL`), arm the cell indicator, and launch the cell
|
||||
/// editor unless the cell's content is locked. Shared by the double-click shortcut
|
||||
/// and the TABLEDIT point pick so both agree on grid coordinates and editor setup.
|
||||
pub(crate) fn begin_table_cell_edit(
|
||||
&mut self,
|
||||
i: usize,
|
||||
handle: acadrust::Handle,
|
||||
click: glam::DVec3,
|
||||
) -> crate::modules::annotate::table_cmd::TableCellEditStart {
|
||||
use crate::modules::annotate::table_cmd::{table_cell_at, TableCellEditStart};
|
||||
let target = if handle != acadrust::Handle::NULL {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(handle)
|
||||
.and_then(|entity| match entity {
|
||||
acadrust::EntityType::Table(table) => Some((handle, table.clone())),
|
||||
_ => None,
|
||||
})
|
||||
} else {
|
||||
// Find all tables whose grid contains `click`.
|
||||
// Iterating document.entities() visits entities in draw order;
|
||||
// taking the last match selects the table on top if they overlap.
|
||||
let mut matched = None;
|
||||
for entity in self.tabs[i].scene.document.entities() {
|
||||
if let acadrust::EntityType::Table(table) = entity {
|
||||
let h = table.common.handle;
|
||||
let style = table.table_style_handle.and_then(|style_handle| {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.objects
|
||||
.get(&style_handle)
|
||||
.and_then(|object| match object {
|
||||
acadrust::objects::ObjectType::TableStyle(style) => Some(style),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if table_cell_at(table, style, click).is_some() {
|
||||
matched = Some((h, table.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
matched
|
||||
};
|
||||
let Some((handle, table)) = target else {
|
||||
return TableCellEditStart::NoCell;
|
||||
};
|
||||
let style = table.table_style_handle.and_then(|style_handle| {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.objects
|
||||
.get(&style_handle)
|
||||
.and_then(|object| match object {
|
||||
acadrust::objects::ObjectType::TableStyle(style) => Some(style),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let Some(hit) = table_cell_at(&table, style, click) else {
|
||||
return TableCellEditStart::NoCell;
|
||||
};
|
||||
// Arm the Properties cell indicator whether or not the cell turns
|
||||
// out to be editable (matches the double-click behavior).
|
||||
let index = hit.index(&table);
|
||||
self.tabs[i].properties.prop_vertex = index;
|
||||
self.tabs[i].properties.prop_vertex_indicator_active = true;
|
||||
crate::entities::table::set_prop_current_cell(index);
|
||||
self.refresh_properties();
|
||||
if hit.locked {
|
||||
return TableCellEditStart::LockedCell;
|
||||
}
|
||||
let command = crate::modules::annotate::table_cmd::TableCellEditCommand::new(
|
||||
handle, &table, hit.row, hit.column,
|
||||
);
|
||||
self.command_line
|
||||
.push_info(&crate::command::CadCommand::prompt(&command));
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
TableCellEditStart::Started
|
||||
}
|
||||
|
||||
fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
match result {
|
||||
@@ -2392,6 +2474,59 @@ impl OpenCADStudio {
|
||||
self.restore_pre_cmd_tangent();
|
||||
let _ = self.dispatch_command(&cmd);
|
||||
}
|
||||
CmdResult::EditTableCell { handle, point } => {
|
||||
// TABLEDIT's pick: end the pick phase and hand (table, point)
|
||||
// to the shared cell-edit launcher. A miss or a locked cell
|
||||
// re-prompts, matching AutoCAD's select-until-valid loop.
|
||||
use crate::modules::annotate::table_cmd::{table_cell_at, TableCellEditStart};
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.tabs[i].snap_result = None;
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
self.restore_pre_cmd_tangent();
|
||||
|
||||
if handle == acadrust::Handle::NULL && self.selection_cycling {
|
||||
let mut candidates = Vec::new();
|
||||
for entity in self.tabs[i].scene.document.entities() {
|
||||
if let acadrust::EntityType::Table(table) = entity {
|
||||
let h = table.common.handle;
|
||||
let style = table.table_style_handle.and_then(|style_handle| {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.objects
|
||||
.get(&style_handle)
|
||||
.and_then(|object| match object {
|
||||
acadrust::objects::ObjectType::TableStyle(style) => Some(style),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if table_cell_at(table, style, point).is_some() {
|
||||
candidates.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
if candidates.len() >= 2 {
|
||||
self.cycle_candidates = Some((self.quick_properties_anchor, candidates));
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
|
||||
match self.begin_table_cell_edit(i, handle, point) {
|
||||
TableCellEditStart::Started => {}
|
||||
TableCellEditStart::LockedCell => {
|
||||
self.command_line.push_error(
|
||||
crate::t!("The cell is content locked.").as_ref(),
|
||||
);
|
||||
let _ = self.dispatch_command("TABLEDIT");
|
||||
}
|
||||
TableCellEditStart::NoCell => {
|
||||
self.command_line.push_error(
|
||||
crate::t!("No editable table cell picked.").as_ref(),
|
||||
);
|
||||
let _ = self.dispatch_command("TABLEDIT");
|
||||
}
|
||||
}
|
||||
}
|
||||
CmdResult::MatchEntityLayer { dest, src } => {
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.tabs[i].snap_result = None;
|
||||
@@ -3454,42 +3589,178 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
// ── EXTRUDE ────────────────────────────────────────────────────
|
||||
CmdResult::ExtrudeEntity {
|
||||
handle,
|
||||
height,
|
||||
CmdResult::ExtrudeEntities {
|
||||
handles,
|
||||
extent,
|
||||
mode,
|
||||
taper_angle,
|
||||
color: _,
|
||||
} => {
|
||||
if self.reject_locked_edit(i, handle) {
|
||||
let delete_sources = self.tabs[i].scene.document.header.delete_objects;
|
||||
if handles.is_empty()
|
||||
|| handles
|
||||
.iter()
|
||||
.any(|handle| self.reject_locked_edit(i, *handle))
|
||||
{
|
||||
self.tabs[i].active_cmd = None;
|
||||
return Task::none();
|
||||
}
|
||||
use crate::modules::insert::solid3d_cmds::empty_solid3d;
|
||||
use crate::command::{ExtrudeExtent, ExtrudeMode};
|
||||
use crate::modules::insert::solid3d_cmds::{
|
||||
empty_extruded_surface, empty_solid3d,
|
||||
};
|
||||
use crate::scene::model::sweep_model;
|
||||
|
||||
let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned();
|
||||
if let Some(entity) = entity_opt {
|
||||
if let Some(solid) = sweep_model::extruded(&entity, height) {
|
||||
let history = crate::scene::model::solid_history::brep_op(&solid);
|
||||
let pending = self.begin_undo(i, "EXTRUDE", 1, true);
|
||||
let created = self.add_solid_model(empty_solid3d(), solid, history);
|
||||
if !created.is_null() {
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output(crate::t!("EXTRUDE: solid created.").as_ref());
|
||||
if let Some(pd) = pending {
|
||||
self.commit_undo_delta(i, pd);
|
||||
}
|
||||
let path = match extent {
|
||||
ExtrudeExtent::Path(handle) => self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(handle)
|
||||
.cloned()
|
||||
.map(|entity| (handle, entity)),
|
||||
_ => None,
|
||||
};
|
||||
if matches!(extent, ExtrudeExtent::Path(_)) && path.is_none() {
|
||||
self.command_line
|
||||
.push_error(crate::t!("EXTRUDE: path entity not found.").as_ref());
|
||||
self.tabs[i].active_cmd = None;
|
||||
return Task::none();
|
||||
}
|
||||
let pending = self.begin_undo(i, "EXTRUDE", handles.len(), true);
|
||||
let mut created_handles = Vec::new();
|
||||
let mut consumed = Vec::new();
|
||||
let mut failed = 0usize;
|
||||
for handle in &handles {
|
||||
let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else {
|
||||
failed += 1;
|
||||
continue;
|
||||
};
|
||||
let Some((profile, closed)) = sweep_model::extrusion_profile_of(&entity) else {
|
||||
failed += 1;
|
||||
continue;
|
||||
};
|
||||
let direction = match extent {
|
||||
ExtrudeExtent::Height(height) => profile.plane.normal().map(|normal| {
|
||||
glam::DVec3::new(
|
||||
normal[0] * height,
|
||||
normal[1] * height,
|
||||
normal[2] * height,
|
||||
)
|
||||
}),
|
||||
ExtrudeExtent::Direction(direction) => Some(direction),
|
||||
ExtrudeExtent::Path(_) => None,
|
||||
};
|
||||
let path_direction = path
|
||||
.as_ref()
|
||||
.and_then(|(_, path)| sweep_model::straight_path_direction(path))
|
||||
.map(glam::DVec3::from_array);
|
||||
// The creation mode controls closed profiles. Open curves
|
||||
// always create sheet surfaces.
|
||||
let creates_surface = matches!(mode, ExtrudeMode::Surface) || !closed;
|
||||
let body = match (creates_surface, &path, direction) {
|
||||
(false, Some((_, path)), _) => {
|
||||
sweep_model::extruded_along_path(&entity, path, taper_angle)
|
||||
}
|
||||
(false, None, Some(direction)) => {
|
||||
sweep_model::extruded_direction(
|
||||
&entity,
|
||||
direction.to_array(),
|
||||
taper_angle,
|
||||
)
|
||||
}
|
||||
(true, None, Some(direction)) => {
|
||||
sweep_model::extruded_surface(
|
||||
&entity,
|
||||
direction.to_array(),
|
||||
taper_angle,
|
||||
)
|
||||
}
|
||||
(true, Some(_), _) => path_direction.and_then(|direction| {
|
||||
sweep_model::extruded_surface(
|
||||
&entity,
|
||||
direction.to_array(),
|
||||
taper_angle,
|
||||
)
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let Some(body) = body else {
|
||||
failed += 1;
|
||||
continue;
|
||||
};
|
||||
let created = if creates_surface {
|
||||
let direction = direction
|
||||
.or(path_direction)
|
||||
.unwrap_or(glam::DVec3::ZERO);
|
||||
self.add_surface_model(
|
||||
empty_extruded_surface(direction, taper_angle),
|
||||
body,
|
||||
)
|
||||
} else {
|
||||
self.command_line.push_error(crate::t!("EXTRUDE: could not build profile. Select a closed 2D entity (Circle, LwPolyline, etc.).").as_ref());
|
||||
let direction = direction.unwrap_or(glam::DVec3::ZERO);
|
||||
let history = path
|
||||
.as_ref()
|
||||
.and_then(|(_, path)| {
|
||||
sweep_model::sweep_history(
|
||||
&entity,
|
||||
path,
|
||||
taper_angle,
|
||||
profile.plane.origin,
|
||||
)
|
||||
})
|
||||
.or_else(|| {
|
||||
sweep_model::extrusion_history(
|
||||
&entity,
|
||||
None,
|
||||
direction.to_array(),
|
||||
taper_angle,
|
||||
profile.plane.origin,
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| crate::scene::model::solid_history::brep_op(&body));
|
||||
self.add_solid_model(empty_solid3d(), body, history)
|
||||
};
|
||||
if created.is_null() {
|
||||
failed += 1;
|
||||
} else {
|
||||
created_handles.push(created);
|
||||
if delete_sources {
|
||||
consumed.push(*handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
if delete_sources && !created_handles.is_empty() {
|
||||
consumed.sort_unstable_by_key(|handle| handle.value());
|
||||
consumed.dedup();
|
||||
self.tabs[i].scene.erase_entities(&consumed);
|
||||
}
|
||||
if !created_handles.is_empty() {
|
||||
self.tabs[i].scene.deselect_all();
|
||||
for handle in &created_handles {
|
||||
self.tabs[i].scene.select_entity(*handle, true);
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(
|
||||
crate::tf!(
|
||||
"EXTRUDE: created %{created} object(s); %{failed} source(s) could not be extruded.",
|
||||
created = created_handles.len(),
|
||||
failed = failed
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
if let Some(pending) = pending {
|
||||
self.commit_undo_delta(i, pending);
|
||||
}
|
||||
} else {
|
||||
self.command_line.push_error(crate::t!("EXTRUDE: entity not found.").as_ref());
|
||||
self.command_line.push_error(crate::t!("EXTRUDE: no selected object could be extruded with the requested options.").as_ref());
|
||||
if let Some(pending) = pending {
|
||||
self.commit_undo_delta(i, pending);
|
||||
}
|
||||
}
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.tabs[i].snap_result = None;
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
self.restore_pre_cmd_tangent();
|
||||
self.refresh_properties();
|
||||
}
|
||||
|
||||
CmdResult::PresspullEntity {
|
||||
@@ -3632,13 +3903,21 @@ impl OpenCADStudio {
|
||||
.get_entity(profile_handle)
|
||||
.cloned();
|
||||
let path_ent = self.tabs[i].scene.document.get_entity(path_handle).cloned();
|
||||
let result = profile_ent
|
||||
.zip(path_ent)
|
||||
.and_then(|(profile, path)| sweep_model::swept(&profile, &path));
|
||||
let result = profile_ent.zip(path_ent).and_then(|(profile, path)| {
|
||||
let reference_point = sweep_model::profile_of(&profile)?.plane.origin;
|
||||
let solid = sweep_model::swept(&profile, &path)?;
|
||||
let history = sweep_model::sweep_history(
|
||||
&profile,
|
||||
&path,
|
||||
0.0,
|
||||
reference_point,
|
||||
)
|
||||
.unwrap_or_else(|| solid_history::brep_op(&solid));
|
||||
Some((solid, history))
|
||||
});
|
||||
|
||||
if let Some(solid) = result {
|
||||
if let Some((solid, history)) = result {
|
||||
let pending = self.begin_undo(i, "SWEEP", 1, true);
|
||||
let history = solid_history::brep_op(&solid);
|
||||
let created = self.add_solid_model(empty_solid3d(), solid, history);
|
||||
if !created.is_null() {
|
||||
self.tabs[i].dirty = true;
|
||||
@@ -3675,9 +3954,20 @@ impl OpenCADStudio {
|
||||
.iter()
|
||||
.filter_map(|handle| self.tabs[i].scene.document.get_entity(*handle).cloned())
|
||||
.collect();
|
||||
if let Some(solid) = sweep_model::lofted(&profiles) {
|
||||
let result = sweep_model::loft_history(&profiles)
|
||||
.and_then(|history| {
|
||||
cadkernel::acis::rebuild_body(&history)
|
||||
.ok()
|
||||
.map(|solid| (solid, history))
|
||||
})
|
||||
.or_else(|| {
|
||||
sweep_model::lofted(&profiles).map(|solid| {
|
||||
let history = solid_history::brep_op(&solid);
|
||||
(solid, history)
|
||||
})
|
||||
});
|
||||
if let Some((solid, history)) = result {
|
||||
let pending = self.begin_undo(i, "LOFT", 1, true);
|
||||
let history = solid_history::brep_op(&solid);
|
||||
let created = self.add_solid_model(empty_solid3d(), solid, history);
|
||||
if !created.is_null() {
|
||||
self.tabs[i].dirty = true;
|
||||
|
||||
@@ -666,6 +666,13 @@ impl OpenCADStudio {
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"TABLEDIT" => {
|
||||
use crate::modules::annotate::table_cmd::TableditCommand;
|
||||
let cmd = TableditCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"DIMCONTINUE" => {
|
||||
use crate::modules::annotate::dim_continue::DimContinueCommand;
|
||||
let scene = &self.tabs[i].scene;
|
||||
|
||||
+21
-12
@@ -736,21 +736,30 @@ impl OpenCADStudio {
|
||||
// ── EXTRUDE ────────────────────────────────────────────────────
|
||||
"EXTRUDE" | "THICKEN" => {
|
||||
use crate::modules::insert::solid3d_cmds::ExtrudeCommand;
|
||||
// If a single entity is already selected, skip the pick step.
|
||||
// A preselection becomes the complete source set; otherwise
|
||||
// the interactive command gathers any number of profiles.
|
||||
let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect();
|
||||
let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer);
|
||||
if selected.len() == 1 {
|
||||
let handle = selected[0].0;
|
||||
if !selected.is_empty() {
|
||||
let mut cmd = ExtrudeCommand::new_named(cmd, color);
|
||||
if let Some(curve) = crate::entities::curve::entity_curve(selected[0].1) {
|
||||
cmd.set_entity_pick_direction(
|
||||
curve.plane.normal().map(glam::DVec3::from_array),
|
||||
);
|
||||
cmd.on_entity_pick(
|
||||
handle,
|
||||
glam::DVec3::from_array(curve.plane.origin),
|
||||
);
|
||||
}
|
||||
let first_curve = selected
|
||||
.iter()
|
||||
.find_map(|(_, entity)| crate::entities::curve::entity_curve(entity));
|
||||
let anchor = first_curve
|
||||
.as_ref()
|
||||
.map(|curve| glam::DVec3::from_array(curve.plane.origin))
|
||||
.unwrap_or(glam::DVec3::ZERO);
|
||||
let direction = first_curve
|
||||
.and_then(|curve| curve.plane.normal())
|
||||
.map(glam::DVec3::from_array);
|
||||
cmd.set_preselection(
|
||||
selected
|
||||
.iter()
|
||||
.map(|(handle, entity)| (*handle, (*entity).clone()))
|
||||
.collect(),
|
||||
anchor,
|
||||
direction,
|
||||
);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
|
||||
@@ -1285,8 +1285,12 @@ impl CadCommand for DrawOrderCommand {
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
!matches!(self.step, DrawOrderStep::SelectObjects)
|
||||
fn input_kind(&self) -> crate::command::InputKind {
|
||||
if matches!(self.step, DrawOrderStep::SelectObjects) {
|
||||
crate::command::InputKind::Point
|
||||
} else {
|
||||
crate::command::InputKind::SingleToken
|
||||
}
|
||||
}
|
||||
|
||||
fn is_selection_gathering(&self) -> bool {
|
||||
@@ -2116,7 +2120,7 @@ mod tests {
|
||||
|
||||
// 1. Shortcut 'F' -> Front
|
||||
let mut cmd = DrawOrderCommand::new(vec![h_hatch]);
|
||||
assert_eq!(cmd.wants_text_input(), true);
|
||||
assert_eq!(cmd.input_kind().wants_text(), true);
|
||||
let res = cmd.on_text_input("F");
|
||||
match res {
|
||||
Some(crate::command::CmdResult::Relaunch(c, handles)) => {
|
||||
@@ -2242,14 +2246,14 @@ mod tests {
|
||||
// Start command with no pre-selection
|
||||
let mut cmd = DrawOrderCommand::new(vec![]);
|
||||
assert!(cmd.is_selection_gathering());
|
||||
assert!(!cmd.wants_text_input());
|
||||
assert!(!cmd.input_kind().wants_text());
|
||||
|
||||
// Gather selection
|
||||
let _ = cmd.on_selection_complete(vec![h_hatch]);
|
||||
let enter_res = cmd.on_enter();
|
||||
assert!(matches!(enter_res, crate::command::CmdResult::NeedPoint));
|
||||
assert!(!cmd.is_selection_gathering());
|
||||
assert!(cmd.wants_text_input());
|
||||
assert!(cmd.input_kind().wants_text());
|
||||
|
||||
// Choose verb Front
|
||||
let res = cmd.on_text_input("F");
|
||||
|
||||
+16
-1
@@ -7,7 +7,7 @@ pub use automation::{export_headless, serve};
|
||||
mod command_driver;
|
||||
pub(crate) mod commands;
|
||||
mod document;
|
||||
mod expr_eval;
|
||||
pub(crate) mod expr_eval;
|
||||
mod find_replace;
|
||||
pub(crate) mod helpers;
|
||||
mod history;
|
||||
@@ -323,6 +323,21 @@ pub enum StartSection {
|
||||
Supporters,
|
||||
}
|
||||
|
||||
/// What the Space / Enter keys currently mean at the command line. One
|
||||
/// decision point (`OpenCADStudio::text_entry_mode`) for every keyboard
|
||||
/// route that used to re-derive the answer from editor state.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum TextEntryMode {
|
||||
/// Normal command line: Space submits, Enter finalises.
|
||||
Command,
|
||||
/// MText preview pane: every key is literal content, Enter is a line break.
|
||||
MTextPreview,
|
||||
/// Free-form text prompt (table cell content, TEXT / MTEXT bodies):
|
||||
/// Space is literal, typed case is preserved, Enter finishes the edit,
|
||||
/// Shift+Enter inserts a line break.
|
||||
FreeText,
|
||||
}
|
||||
|
||||
pub(super) struct OpenCADStudio {
|
||||
start: Instant,
|
||||
tabs: Vec<DocumentTab>,
|
||||
|
||||
@@ -40,6 +40,33 @@ impl super::OpenCADStudio {
|
||||
handle
|
||||
}
|
||||
|
||||
/// Add an open sheet body as a persistent Surface entity and register its
|
||||
/// exact B-rep for shaded and wireframe display.
|
||||
pub(super) fn add_surface_model(
|
||||
&mut self,
|
||||
mut entity: EntityType,
|
||||
surface: Body,
|
||||
) -> Handle {
|
||||
let i = self.active_tab;
|
||||
let EntityType::Surface(inner) = &mut entity else {
|
||||
return Handle::NULL;
|
||||
};
|
||||
inner.common.plotstyle_flags = 2;
|
||||
inner.wires = solid_model::edge_wires(&surface);
|
||||
let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&surface)
|
||||
else {
|
||||
self.command_line
|
||||
.push_error(crate::t!("The surface could not be encoded as ACIS.").as_ref());
|
||||
return Handle::NULL;
|
||||
};
|
||||
inner.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string());
|
||||
let Some(handle) = self.commit_entity_handle(entity) else {
|
||||
return Handle::NULL;
|
||||
};
|
||||
self.tabs[i].scene.register_solid_model(handle, surface);
|
||||
handle
|
||||
}
|
||||
|
||||
fn selected_solid_handles(&mut self) -> Vec<Handle> {
|
||||
let i = self.active_tab;
|
||||
let mut handles: Vec<Handle> = self.tabs[i]
|
||||
|
||||
+71
-15
@@ -8,6 +8,7 @@ use crate::app::helpers::{
|
||||
CoordKind,
|
||||
};
|
||||
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
|
||||
use crate::app::TextEntryMode;
|
||||
use crate::modules::ModuleEvent;
|
||||
use crate::scene::pick::grip::{
|
||||
find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripEditMode,
|
||||
@@ -199,8 +200,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
.get_or_insert_with(String::new)
|
||||
.push_str(&s);
|
||||
} else {
|
||||
// Command-line entry is shown uppercase.
|
||||
self.command_line.input.push_str(&s.to_uppercase());
|
||||
// Command-line entry is shown uppercase — except in
|
||||
// free-form text prompts, where the typed case is the
|
||||
// content (matches the CommandInput route).
|
||||
if self.is_free_text_active() {
|
||||
self.command_line.input.push_str(&s);
|
||||
} else {
|
||||
self.command_line.input.push_str(&s.to_uppercase());
|
||||
}
|
||||
self.command_line.cancel_history_navigation();
|
||||
// Live incremental search for INSERT/MINSERT (see CommandInput)
|
||||
let live = self.command_line.input.clone();
|
||||
@@ -263,7 +270,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
self.command_line.close_history();
|
||||
// A leading `>` was only a "literal spaces" typing hint (see
|
||||
// CommandSpace) — drop it before the input is interpreted.
|
||||
if self.command_line.input.starts_with('>') {
|
||||
// Free-form text prompts keep it: there it is content, not a
|
||||
// hint, so a cell value like `>Note` commits verbatim.
|
||||
if !self.is_free_text_active() && self.command_line.input.starts_with('>') {
|
||||
self.command_line.input.remove(0);
|
||||
}
|
||||
// Grip-menu value prompt — consume the typed number and
|
||||
@@ -516,7 +525,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
let wants_spaces = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.wants_text_input() && c.wants_text_with_spaces())
|
||||
.map(|c| c.is_free_text_step())
|
||||
.unwrap_or(false);
|
||||
let raw = self.command_line.input.clone();
|
||||
let toks: Vec<String> = raw.split_whitespace().map(String::from).collect();
|
||||
@@ -542,7 +551,20 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
}
|
||||
}
|
||||
if self.tabs[i].active_cmd.is_some() {
|
||||
let text = crate::app::expr_eval::eval_to_string(self.command_line.input.trim());
|
||||
// Free-form text is the content itself: no expression
|
||||
// evaluation, or a cell value like `5*2` would commit as
|
||||
// `10`. Single-token prompts keep the calculator behavior.
|
||||
let free_text = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.is_free_text_step())
|
||||
.unwrap_or(false);
|
||||
let raw = self.command_line.input.trim().to_string();
|
||||
let text = if free_text {
|
||||
raw
|
||||
} else {
|
||||
crate::app::expr_eval::eval_to_string(&raw)
|
||||
};
|
||||
self.command_line.input.clear();
|
||||
|
||||
// Offer the typed text to the command's option handler
|
||||
@@ -559,16 +581,23 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
if self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.wants_text_input())
|
||||
.map(|c| c.input_kind().wants_text())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
self.push_ucs_to_cmd(i);
|
||||
if let Some(result) = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_mut()
|
||||
.and_then(|c| c.on_text_input(&text))
|
||||
{
|
||||
return self.apply_cmd_result(result);
|
||||
// An empty submit must not be handed to on_text_input —
|
||||
// a text step would commit the empty string (wiping a
|
||||
// table cell, clearing an attribute). It falls through
|
||||
// to the Enter handling below, which ends the step the
|
||||
// same way a bare Enter does: unchanged.
|
||||
if !text.is_empty() {
|
||||
self.push_ucs_to_cmd(i);
|
||||
if let Some(result) = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_mut()
|
||||
.and_then(|c| c.on_text_input(&text))
|
||||
{
|
||||
return self.apply_cmd_result(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,9 +708,36 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// The active command's current step collects free-form prose from the
|
||||
/// command line. Single decision point for the routes that type into
|
||||
/// the buffer (CommandInput, Shift+Enter, the view's on_submit wiring).
|
||||
pub(crate) fn is_free_text_active(&self) -> bool {
|
||||
self.tabs[self.active_tab]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.is_free_text_step())
|
||||
}
|
||||
|
||||
/// What Space / Enter currently mean. Previously every key handler
|
||||
/// re-derived this from the MText editor and the active command, and
|
||||
/// the MText preview case was known only to `CommandSpace` — so Enter
|
||||
/// behaved differently depending on which route carried the key.
|
||||
pub(crate) fn text_entry_mode(&self) -> crate::app::TextEntryMode {
|
||||
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
|
||||
return TextEntryMode::MTextPreview;
|
||||
}
|
||||
if self.is_free_text_active() {
|
||||
return TextEntryMode::FreeText;
|
||||
}
|
||||
TextEntryMode::Command
|
||||
}
|
||||
|
||||
pub(super) fn on_command_finalize(&mut self) -> Task<Message> {
|
||||
// In the MText preview, Enter inserts a line break.
|
||||
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
|
||||
// In the MText preview, Enter inserts a line break. The
|
||||
// free-text prompt case falls through deliberately: for it
|
||||
// Enter *finishes* the edit (Shift+Enter breaks the line,
|
||||
// handled by the SHIFT+ENTER shortcut route).
|
||||
if self.text_entry_mode() == TextEntryMode::MTextPreview {
|
||||
self.mtext_type("\n");
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ impl OpenCADStudio {
|
||||
let wants_text = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.wants_text_input())
|
||||
.map(|c| c.input_kind().wants_text())
|
||||
.unwrap_or(false);
|
||||
// A point step that also accepts keyword letters (PLINE A/L/C…) keeps
|
||||
// its polar boxes: only letters reach the command line, digits stay
|
||||
@@ -604,7 +604,7 @@ impl OpenCADStudio {
|
||||
let wants_text = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.wants_text_input())
|
||||
.map(|c| c.input_kind().wants_text())
|
||||
.unwrap_or(false);
|
||||
if !wants_text {
|
||||
if let Some(text) = self.tabs[i]
|
||||
@@ -645,7 +645,7 @@ impl OpenCADStudio {
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| {
|
||||
(c.wants_text_input() && !c.point_step_accepts_keywords())
|
||||
(c.input_kind().wants_text() && !c.point_step_accepts_keywords())
|
||||
|| c.dyn_commit_as_text()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
+170
-19
@@ -1,4 +1,4 @@
|
||||
use super::{ArrowKey, Message, OpenCADStudio};
|
||||
use super::{ArrowKey, Message, OpenCADStudio, TextEntryMode};
|
||||
use crate::scene::VIEWCUBE_DRAW_PX;
|
||||
use crate::ui::PropertiesPanel;
|
||||
use iced::time::Instant;
|
||||
@@ -1478,14 +1478,23 @@ impl OpenCADStudio {
|
||||
// string, a path, `UCS Z 90` as one line) can be typed; the `>`
|
||||
// is stripped on submit. (Unfocused Space repeats the last
|
||||
// command via CommandSpace.)
|
||||
// Command-line entry is shown uppercase.
|
||||
let s = s.to_uppercase();
|
||||
// Command-line entry is shown uppercase — except while the
|
||||
// active command collects free-form text (cell text, TEXT /
|
||||
// MTEXT bodies), where the typed case is the content and
|
||||
// Space must stay in the buffer.
|
||||
let text_with_spaces = self.is_free_text_active();
|
||||
let s = if text_with_spaces { s } else { s.to_uppercase() };
|
||||
// A space submits (acts like Enter). The whole value is handed to
|
||||
// the submit path, which tokenises multi-token lines — so a typed
|
||||
// token, a pasted `LINE 0,0 10,10`, or API-fed text all run their
|
||||
// spaces as step separators. A leading `>` keeps spaces literal.
|
||||
// spaces as step separators. A leading `>` keeps spaces literal,
|
||||
// as does an active free-form text prompt.
|
||||
let sweep = self.sync_active_field_if_any();
|
||||
if !self.command_line.literal_spaces && !s.starts_with('>') && s.contains(' ') {
|
||||
if !text_with_spaces
|
||||
&& !self.command_line.literal_spaces
|
||||
&& !s.starts_with('>')
|
||||
&& s.contains(' ')
|
||||
{
|
||||
self.command_line.input = s;
|
||||
return Task::batch(vec![sweep, self.update(Message::CommandSubmit)]);
|
||||
}
|
||||
@@ -1817,21 +1826,35 @@ impl OpenCADStudio {
|
||||
Message::CommandSubmit => self.on_command_submit(),
|
||||
|
||||
Message::CommandSpace => {
|
||||
// Space is a literal space inside the MText preview; otherwise
|
||||
// it finalises the active command like Enter.
|
||||
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
|
||||
self.mtext_type(" ");
|
||||
return Task::none();
|
||||
// What Space means is decided in one place: the MText preview
|
||||
// and free-form text prompts take it as a literal character,
|
||||
// everything else finalises the active command like Enter.
|
||||
match self.text_entry_mode() {
|
||||
TextEntryMode::MTextPreview => {
|
||||
self.mtext_type(" ");
|
||||
Task::none()
|
||||
}
|
||||
// A free-form text prompt (table cell content, TEXT /
|
||||
// MTEXT bodies) keeps Space in the buffer as a literal
|
||||
// character.
|
||||
TextEntryMode::FreeText => {
|
||||
self.command_line.input.push(' ');
|
||||
Task::none()
|
||||
}
|
||||
TextEntryMode::Command => {
|
||||
// A leading `>` (or the persistent `>` toggle) puts
|
||||
// the command line in "literal space" mode so the user
|
||||
// can type arguments that contain spaces. The typed
|
||||
// `>` is stripped on submit.
|
||||
if self.command_line.literal_spaces
|
||||
|| self.command_line.input.starts_with('>')
|
||||
{
|
||||
self.command_line.input.push(' ');
|
||||
return Task::none();
|
||||
}
|
||||
self.update(Message::CommandFinalize)
|
||||
}
|
||||
}
|
||||
// A leading `>` (or the persistent `>` toggle) puts the command
|
||||
// line in "literal space" mode so the user can type arguments
|
||||
// that contain spaces; otherwise Space works like Enter. The
|
||||
// typed `>` is stripped on submit.
|
||||
if self.command_line.literal_spaces || self.command_line.input.starts_with('>') {
|
||||
self.command_line.input.push(' ');
|
||||
return Task::none();
|
||||
}
|
||||
return self.update(Message::CommandFinalize);
|
||||
}
|
||||
Message::CommandFinalize => self.on_command_finalize(),
|
||||
|
||||
@@ -8201,3 +8224,131 @@ mod prop_pointer_tests {
|
||||
assert!(task.units() > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod free_text_entry_tests {
|
||||
//! The free-form text prompts (table cell content, TEXT / MTEXT bodies)
|
||||
//! change what Space / Enter / typing mean at the command line. These
|
||||
//! tests pin the routing through `text_entry_mode` so the three key
|
||||
//! routes can't drift apart again.
|
||||
use super::Message;
|
||||
use crate::app::{OpenCADStudio, TextEntryMode};
|
||||
use crate::modules::annotate::table_cmd::TableCellEditCommand;
|
||||
use acadrust::entities::Table;
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{EntityType, Handle};
|
||||
|
||||
/// A test drawing with a 2×2 table and the cell editor active on [0,0],
|
||||
/// seeded with `cell_text`.
|
||||
fn app_with_cell_command(cell_text: &str) -> (OpenCADStudio, Handle) {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let mut table = Table::new(Vector3::new(0.0, 0.0, 0.0), 2, 2);
|
||||
table.cell_mut(0, 0).unwrap().set_text(cell_text);
|
||||
let handle = app.tabs[0]
|
||||
.scene
|
||||
.document
|
||||
.add_entity(EntityType::Table(table.clone()))
|
||||
.unwrap();
|
||||
app.set_active_command(0, Box::new(TableCellEditCommand::new(handle, &table, 0, 0)));
|
||||
(app, handle)
|
||||
}
|
||||
|
||||
fn cell_text(app: &OpenCADStudio, _handle: Handle) -> String {
|
||||
// ReplaceMany commits by erasing the old handle and re-adding the
|
||||
// entity under a new one, so look the (single) table up by type.
|
||||
app.tabs[0]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.find_map(|e| match e {
|
||||
EntityType::Table(table) => Some(table.cell_text(0, 0).unwrap_or("").to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("table entity missing")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_editor_switches_to_free_text_mode() {
|
||||
let (mut app, _) = app_with_cell_command("");
|
||||
assert_eq!(app.text_entry_mode(), TextEntryMode::FreeText);
|
||||
assert!(app.is_free_text_active());
|
||||
app.tabs[0].active_cmd = None;
|
||||
assert_eq!(app.text_entry_mode(), TextEntryMode::Command);
|
||||
assert!(!app.is_free_text_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_edit_typed_space_stays_in_buffer_with_case() {
|
||||
let (mut app, handle) = app_with_cell_command("");
|
||||
// A typed buffer containing a Space must not auto-submit.
|
||||
let _ = app.update(Message::CommandInput("Foo Bar".into()));
|
||||
assert_eq!(app.command_line.input, "Foo Bar");
|
||||
assert!(app.tabs[0].active_cmd.is_some(), "Space must not submit");
|
||||
// The Space key binding inserts a literal space too.
|
||||
let _ = app.update(Message::CommandSpace);
|
||||
assert_eq!(app.command_line.input, "Foo Bar ");
|
||||
// Enter finishes the edit and commits the content, case intact.
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
assert_eq!(cell_text(&app, handle), "Foo Bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_edit_slash_n_inserts_line_break() {
|
||||
let (mut app, handle) = app_with_cell_command("");
|
||||
let _ = app.update(Message::CommandInput("Foo/nBar".into()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
assert_eq!(cell_text(&app, handle), "Foo\\PBar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_edit_unfocused_typing_preserves_case() {
|
||||
// The CommandAppendChar route (typing while the command line is not
|
||||
// focused) must not uppercase free-form text either.
|
||||
let (mut app, handle) = app_with_cell_command("");
|
||||
for ch in ["F", "o", "o"] {
|
||||
let _ = app.update(Message::CommandAppendChar(ch.into()));
|
||||
}
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
assert_eq!(cell_text(&app, handle), "Foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_edit_skips_expression_evaluation() {
|
||||
let (mut app, handle) = app_with_cell_command("");
|
||||
let _ = app.update(Message::CommandInput("5*2".into()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
assert_eq!(cell_text(&app, handle), "5*2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_edit_leading_greater_than_is_content() {
|
||||
// `>` is only a literal-space hint for command prompts; in free
|
||||
// text it is content and must not be stripped on submit.
|
||||
let (mut app, handle) = app_with_cell_command("");
|
||||
let _ = app.update(Message::CommandInput(">Note".into()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
assert_eq!(cell_text(&app, handle), ">Note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_edit_empty_enter_ends_edit_without_wiping() {
|
||||
let (mut app, handle) = app_with_cell_command("Keep me");
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
assert_eq!(cell_text(&app, handle), "Keep me");
|
||||
assert!(app.tabs[0].active_cmd.is_none(), "Enter ends the edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_commands_still_uppercase_and_submit_on_space() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
// No free-text command active: entry is uppercased…
|
||||
let _ = app.update(Message::CommandInput("lin".into()));
|
||||
assert_eq!(app.command_line.input, "LIN");
|
||||
// …and a pasted multi-token line runs as a command, not as text.
|
||||
let _ = app.update(Message::CommandInput("LINE 0,0 10,10".into()));
|
||||
assert!(app.command_line.input.is_empty(), "Space submitted the line");
|
||||
assert_eq!(app.text_entry_mode(), TextEntryMode::Command);
|
||||
}
|
||||
}
|
||||
+43
-110
@@ -2690,6 +2690,19 @@ impl OpenCADStudio {
|
||||
};
|
||||
let (vw, vh) = vp_size;
|
||||
|
||||
// An engaged grip owns the next left press (click-move-click placement
|
||||
// or the release of a press-drag). Do not let the same press arm the
|
||||
// normal box/lasso state or trigger another viewport control; the
|
||||
// release path below will commit the grip.
|
||||
if self.tabs[i].active_grip.is_some() {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.selection
|
||||
.borrow_mut()
|
||||
.clear_left_selection_gesture();
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
// Interactive navigation tools reuse the middle-button movement path,
|
||||
// so no selection/pick logic runs while the left button drives them.
|
||||
if self.tabs[i].orbit_mode
|
||||
@@ -2879,6 +2892,11 @@ impl OpenCADStudio {
|
||||
));
|
||||
self.grip_hover = None;
|
||||
self.grip_popup = None;
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.selection
|
||||
.borrow_mut()
|
||||
.clear_left_selection_gesture();
|
||||
|
||||
// A grip edit is not a CAD command, so explicitly seed the shared
|
||||
// dynamic-input fields for the newly engaged grip.
|
||||
@@ -2956,10 +2974,7 @@ impl OpenCADStudio {
|
||||
// click doesn't read as an in-progress drag on later moves.
|
||||
{
|
||||
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
||||
sel.left_down = false;
|
||||
sel.left_press_pos = None;
|
||||
sel.left_press_time = None;
|
||||
sel.left_dragging = false;
|
||||
sel.clear_left_selection_gesture();
|
||||
}
|
||||
let moved = grip.last_world != grip.origin_world;
|
||||
if is_click && !moved {
|
||||
@@ -3602,7 +3617,7 @@ impl OpenCADStudio {
|
||||
let wants_text = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.wants_text_input())
|
||||
.map(|c| c.input_kind().wants_text())
|
||||
.unwrap_or(false);
|
||||
if wants_text {
|
||||
if let Some(text) = self.tabs[i]
|
||||
@@ -4033,16 +4048,16 @@ impl OpenCADStudio {
|
||||
)
|
||||
})
|
||||
.or_else(|| {
|
||||
// 3D solids: click anywhere on the shaded
|
||||
// body — top-level solids and block-internal
|
||||
// ones together, front-most wins (a block in
|
||||
// front of a solid resolves to the block).
|
||||
self.tabs[i].scene.solid_click_hit(
|
||||
// Normal 3D selection follows the displayed B-rep
|
||||
// edges. Face-interior picking remains reserved for
|
||||
// modelling commands that explicitly request it.
|
||||
self.tabs[i].scene.solid_edge_click_hit(
|
||||
p,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
candidate_handles.as_ref(),
|
||||
crate::ui::overlay::pick_box_aperture_px(self.pick_box),
|
||||
)
|
||||
});
|
||||
// Selection filter: drop a pick whose type is excluded.
|
||||
@@ -4352,12 +4367,13 @@ impl OpenCADStudio {
|
||||
)
|
||||
.and_then(|s| Scene::handle_from_wire_name(s))
|
||||
.or_else(|| {
|
||||
self.tabs[i].scene.solid_click_hit(
|
||||
self.tabs[i].scene.solid_edge_click_hit(
|
||||
p,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
candidate_handles.as_ref(),
|
||||
crate::ui::overlay::pick_box_aperture_px(self.pick_box),
|
||||
)
|
||||
});
|
||||
if let Some(handle) = hit {
|
||||
@@ -4369,102 +4385,14 @@ impl OpenCADStudio {
|
||||
).as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
let table_editor = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(handle)
|
||||
.and_then(|entity| match entity {
|
||||
AcadEntityType::Table(table) => {
|
||||
let horizontal = glam::DVec3::new(
|
||||
table.horizontal_direction.x,
|
||||
table.horizontal_direction.y,
|
||||
table.horizontal_direction.z,
|
||||
)
|
||||
.normalize_or(glam::DVec3::X);
|
||||
let normal = glam::DVec3::new(
|
||||
table.normal.x,
|
||||
table.normal.y,
|
||||
table.normal.z,
|
||||
)
|
||||
.normalize_or(glam::DVec3::Z);
|
||||
let mut down = horizontal
|
||||
.cross(normal)
|
||||
.normalize_or(glam::DVec3::NEG_Y);
|
||||
let table_style = table.table_style_handle.and_then(|style_handle| {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.objects
|
||||
.get(&style_handle)
|
||||
.and_then(|object| match object {
|
||||
acadrust::objects::ObjectType::TableStyle(style) => {
|
||||
Some(style)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let flows_up = crate::entities::table::resolved_flow_up(
|
||||
table,
|
||||
table_style,
|
||||
);
|
||||
if flows_up {
|
||||
down = -down;
|
||||
}
|
||||
let origin = glam::DVec3::new(
|
||||
table.insertion_point.x,
|
||||
table.insertion_point.y,
|
||||
table.insertion_point.z,
|
||||
);
|
||||
let relative = click_world - origin;
|
||||
let x = relative.dot(horizontal);
|
||||
let y = relative.dot(down);
|
||||
if x < 0.0 || y < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let column = table
|
||||
.columns
|
||||
.iter()
|
||||
.scan(0.0, |offset, column| {
|
||||
*offset += column.width;
|
||||
Some(*offset)
|
||||
})
|
||||
.position(|end| x <= end)?;
|
||||
let row = table
|
||||
.rows
|
||||
.iter()
|
||||
.scan(0.0, |offset, row| {
|
||||
*offset += row.height;
|
||||
Some(*offset)
|
||||
})
|
||||
.position(|end| y <= end)?;
|
||||
let locked = table.cell(row, column).is_some_and(|cell| {
|
||||
use acadrust::entities::table::CellStateFlags;
|
||||
cell.state.intersects(
|
||||
CellStateFlags::CONTENT_LOCKED
|
||||
| CellStateFlags::CONTENT_READ_ONLY,
|
||||
)
|
||||
});
|
||||
Some((
|
||||
(!locked).then(|| {
|
||||
crate::modules::annotate::table_cmd::TableCellEditCommand::new(
|
||||
handle, table, row, column,
|
||||
)
|
||||
}),
|
||||
row * table.column_count() + column,
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some((command, cell_index)) = table_editor {
|
||||
self.tabs[i].properties.prop_vertex = cell_index;
|
||||
self.tabs[i].properties.prop_vertex_indicator_active = true;
|
||||
crate::entities::table::set_prop_current_cell(cell_index);
|
||||
self.refresh_properties();
|
||||
if let Some(command) = command {
|
||||
self.command_line
|
||||
.push_info(&crate::command::CadCommand::prompt(&command));
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
}
|
||||
// Table double-click: shared TABLEDIT editor setup. A
|
||||
// grid miss returns NoCell and falls through to the
|
||||
// other text/entity editors below; an editable cell or
|
||||
// a locked cell (indicator armed, no editor) ends the
|
||||
// double-click handling here.
|
||||
if self.begin_table_cell_edit(i, handle, click_world)
|
||||
!= crate::modules::annotate::table_cmd::TableCellEditStart::NoCell
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Any text-bearing entity opens its in-place editor
|
||||
@@ -5053,9 +4981,14 @@ impl OpenCADStudio {
|
||||
}
|
||||
if hovered.is_none() {
|
||||
let started = Instant::now();
|
||||
hovered = self.tabs[i]
|
||||
.scene
|
||||
.solid_hover_hit(p, view_rot, eye, bounds, candidate_handles.as_ref());
|
||||
hovered = self.tabs[i].scene.solid_edge_hover_hit(
|
||||
p,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
candidate_handles.as_ref(),
|
||||
crate::ui::overlay::pick_box_aperture_px(self.pick_box),
|
||||
);
|
||||
solid_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
}
|
||||
self.tabs[i].scene.set_hover_highlight(hovered);
|
||||
|
||||
+80
-11
@@ -1129,6 +1129,19 @@ impl CadCommand for TCountCommand {
|
||||
}
|
||||
// ── Result token ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ExtrudeMode {
|
||||
Solid,
|
||||
Surface,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum ExtrudeExtent {
|
||||
Height(f64),
|
||||
Direction(DVec3),
|
||||
Path(Handle),
|
||||
}
|
||||
|
||||
/// Returned by every `CadCommand` method to tell main.rs what to do.
|
||||
#[allow(dead_code)]
|
||||
pub enum CmdResult {
|
||||
@@ -1240,6 +1253,12 @@ pub enum CmdResult {
|
||||
},
|
||||
/// Cancel: discard any preview and end the command.
|
||||
Cancel,
|
||||
/// End the command and begin in-place editing of the table cell under
|
||||
/// `point` on table `handle` (TABLEDIT). The host resolves the cell —
|
||||
/// it owns the document needed for the table-style lookup — honors
|
||||
/// content locks, and launches the cell editor, re-prompting the
|
||||
/// command when the pick misses a cell.
|
||||
EditTableCell { handle: Handle, point: DVec3 },
|
||||
/// Cancel because the active drawing space changed. Cleanup is identical
|
||||
/// to `Cancel`, but the host reports the context change explicitly.
|
||||
CancelForSpaceChange,
|
||||
@@ -1408,10 +1427,12 @@ pub enum CmdResult {
|
||||
/// Translation vector applied once to every selected point.
|
||||
delta: DVec3,
|
||||
},
|
||||
/// Extrude the profile entity `handle` along its plane normal.
|
||||
ExtrudeEntity {
|
||||
handle: Handle,
|
||||
height: f64,
|
||||
/// Extrude one or more profiles with the requested construction mode.
|
||||
ExtrudeEntities {
|
||||
handles: Vec<Handle>,
|
||||
extent: ExtrudeExtent,
|
||||
mode: ExtrudeMode,
|
||||
taper_angle: f64,
|
||||
color: [f32; 4],
|
||||
},
|
||||
/// Pull a closed profile or a planar solid face by a signed distance.
|
||||
@@ -1678,6 +1699,38 @@ impl CmdOption {
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of input the command's current step expects from the command line.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum InputKind {
|
||||
/// Normal point-picking / viewport interaction or keyword options.
|
||||
/// The command line does not await dedicated text input.
|
||||
#[default]
|
||||
Point,
|
||||
/// Single-token command-line text input (option letters, numeric radius,
|
||||
/// layer name, block name, numeric angle). Space acts as a submit delimiter,
|
||||
/// characters are automatically uppercased, and expressions like `5*2`
|
||||
/// are evaluated.
|
||||
SingleToken,
|
||||
/// Free-form text prose (table cell contents, TEXT / MTEXT bodies,
|
||||
/// dimension text overrides, attribute prompt defaults). Space is a
|
||||
/// literal character, typed letter casing is preserved, math expression
|
||||
/// evaluation is bypassed, Enter finishes the edit, and Shift+Enter
|
||||
/// inserts a line break.
|
||||
FreeText,
|
||||
}
|
||||
|
||||
impl InputKind {
|
||||
/// Returns `true` when the command is waiting for typed text input (`SingleToken` or `FreeText`).
|
||||
pub fn wants_text(self) -> bool {
|
||||
matches!(self, InputKind::SingleToken | InputKind::FreeText)
|
||||
}
|
||||
|
||||
/// Returns `true` when the current prompt collects free-form prose.
|
||||
pub fn is_free_text(self) -> bool {
|
||||
self == InputKind::FreeText
|
||||
}
|
||||
}
|
||||
|
||||
pub trait CadCommand: Send {
|
||||
/// Short name shown in the command line prompt, e.g. `"LINE"`.
|
||||
#[allow(dead_code)]
|
||||
@@ -1904,7 +1957,21 @@ pub trait CadCommand: Send {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// What kind of input the current step expects from the command line.
|
||||
/// Default is [`InputKind::Point`].
|
||||
fn input_kind(&self) -> InputKind {
|
||||
#[allow(deprecated)]
|
||||
if self.wants_text_with_spaces() {
|
||||
InputKind::FreeText
|
||||
} else if self.wants_text_input() {
|
||||
InputKind::SingleToken
|
||||
} else {
|
||||
InputKind::Point
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when the command is waiting for text typed in the command line.
|
||||
#[deprecated(note = "Use `input_kind().wants_text()` instead")]
|
||||
fn wants_text_input(&self) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -1931,17 +1998,19 @@ pub trait CadCommand: Send {
|
||||
|
||||
/// Returns `true` when the active text prompt expects free-form prose
|
||||
/// that can legitimately contain whitespace (the body of a TEXT /
|
||||
/// MTEXT / DDEDIT entity, an attribute default value, etc.). For
|
||||
/// these prompts the command-line input must let `Space` be typed as
|
||||
/// a literal character; for every other prompt `Space` submits the
|
||||
/// input the same way `Enter` does.
|
||||
///
|
||||
/// Default `false` — single-token prompts (option letters, numeric
|
||||
/// radius, block name) do not embed spaces.
|
||||
/// MTEXT / DDEDIT entity, a table cell, an attribute default value).
|
||||
#[deprecated(note = "Use `input_kind().is_free_text()` instead")]
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The current step collects free-form prose from the command line:
|
||||
/// Space is a literal character, typed case is preserved, Enter
|
||||
/// finishes the edit and Shift+Enter inserts a line break.
|
||||
fn is_free_text_step(&self) -> bool {
|
||||
self.input_kind().is_free_text()
|
||||
}
|
||||
|
||||
/// Called when the user submits text via the command line while `wants_text_input` is true.
|
||||
fn on_text_input(&mut self, _text: &str) -> Option<CmdResult> {
|
||||
None
|
||||
|
||||
@@ -5,7 +5,9 @@ use acadrust::types::{Handle, Vector3};
|
||||
use acadrust::EntityType;
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult, DimensionAssociationInput, WorkingPlane};
|
||||
use crate::command::{
|
||||
CadCommand, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane,
|
||||
};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use crate::t;
|
||||
@@ -159,8 +161,12 @@ impl CadCommand for AlignedDimensionCommand {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
true
|
||||
fn input_kind(&self) -> InputKind {
|
||||
if self.awaiting_text {
|
||||
InputKind::FreeText
|
||||
} else {
|
||||
InputKind::SingleToken
|
||||
}
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
@@ -169,11 +175,6 @@ impl CadCommand for AlignedDimensionCommand {
|
||||
!self.awaiting_text && !self.awaiting_angle
|
||||
}
|
||||
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
// The override text may contain spaces.
|
||||
self.awaiting_text
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if self.awaiting_text {
|
||||
let t = text.trim();
|
||||
|
||||
@@ -8,7 +8,7 @@ use cadkernel::geom2d::{
|
||||
|
||||
use crate::command::{
|
||||
CadCommand, CmdOption, CmdResult, DimensionAssociationInput,
|
||||
DimensionAssociationSource, WorkingPlane,
|
||||
DimensionAssociationSource, InputKind, WorkingPlane,
|
||||
};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
@@ -486,12 +486,12 @@ impl CadCommand for AngularDimensionCommand {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
self.awaiting_text
|
||||
fn input_kind(&self) -> InputKind {
|
||||
if self.awaiting_text {
|
||||
InputKind::FreeText
|
||||
} else {
|
||||
InputKind::SingleToken
|
||||
}
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
|
||||
@@ -6,7 +6,7 @@ use glam::DVec3;
|
||||
|
||||
use crate::command::{
|
||||
CadCommand, CmdOption, CmdResult, DimensionAssociationInput,
|
||||
DimensionAssociationSource, WorkingPlane,
|
||||
DimensionAssociationSource, InputKind, WorkingPlane,
|
||||
};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::dimension_assoc::{
|
||||
@@ -350,18 +350,18 @@ impl CadCommand for ArcLengthDimensionCommand {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
true
|
||||
fn input_kind(&self) -> InputKind {
|
||||
if self.awaiting_text {
|
||||
InputKind::FreeText
|
||||
} else {
|
||||
InputKind::SingleToken
|
||||
}
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
!self.awaiting_text && !self.awaiting_angle
|
||||
}
|
||||
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
self.awaiting_text
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
let Step::DimLine(selection) = self.step else {
|
||||
return Vec::new();
|
||||
|
||||
@@ -4,7 +4,7 @@ use acadrust::EntityType;
|
||||
use glam::{DVec3, Vec3};
|
||||
|
||||
use crate::command::{
|
||||
CadCommand, CmdOption, CmdResult, DimensionAssociationInput, WorkingPlane,
|
||||
CadCommand, CmdOption, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane,
|
||||
};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
@@ -136,18 +136,18 @@ impl CadCommand for DiameterDimensionCommand {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
true
|
||||
fn input_kind(&self) -> InputKind {
|
||||
if self.awaiting_text {
|
||||
InputKind::FreeText
|
||||
} else {
|
||||
InputKind::SingleToken
|
||||
}
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
!self.awaiting_text && !self.awaiting_angle
|
||||
}
|
||||
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
self.awaiting_text
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
if matches!(self.step, Step::DimLine(_)) && !self.awaiting_text && !self.awaiting_angle {
|
||||
vec![
|
||||
|
||||
@@ -5,7 +5,7 @@ use glam::{DVec3, Vec3};
|
||||
|
||||
use crate::command::{
|
||||
CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionAssociationSource,
|
||||
WorkingPlane,
|
||||
InputKind, WorkingPlane,
|
||||
};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::creation_style::DimensionCreationDefaults;
|
||||
@@ -227,18 +227,18 @@ impl CadCommand for JoggedRadiusDimensionCommand {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
true
|
||||
fn input_kind(&self) -> InputKind {
|
||||
if self.awaiting_text {
|
||||
InputKind::FreeText
|
||||
} else {
|
||||
InputKind::SingleToken
|
||||
}
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
!self.awaiting_text && !self.awaiting_angle
|
||||
}
|
||||
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
self.awaiting_text
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
if matches!(self.step, Step::DimLine { .. })
|
||||
&& !self.awaiting_text
|
||||
|
||||
@@ -5,7 +5,9 @@ use cadkernel::geom2d::{
|
||||
closest_point, Circle as KernelCircle, Curve, Line as KernelLine,
|
||||
};
|
||||
|
||||
use crate::command::{CadCommand, CmdResult, DimensionAssociationInput, WorkingPlane};
|
||||
use crate::command::{
|
||||
CadCommand, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane,
|
||||
};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use glam::DVec3;
|
||||
@@ -219,8 +221,12 @@ impl CadCommand for LinearDimensionCommand {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
true
|
||||
fn input_kind(&self) -> InputKind {
|
||||
if self.awaiting_text {
|
||||
InputKind::FreeText
|
||||
} else {
|
||||
InputKind::SingleToken
|
||||
}
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
@@ -229,11 +235,6 @@ impl CadCommand for LinearDimensionCommand {
|
||||
!self.awaiting_text && !self.awaiting_angle && !self.awaiting_rotation
|
||||
}
|
||||
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
// The override text may contain spaces.
|
||||
self.awaiting_text
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if self.awaiting_text {
|
||||
let t = text.trim();
|
||||
|
||||
@@ -5,7 +5,7 @@ use acadrust::types::Vector3;
|
||||
use acadrust::EntityType;
|
||||
use glam::{DVec3, Mat4, Vec3};
|
||||
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane};
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, WorkingPlane};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use crate::t;
|
||||
@@ -469,12 +469,12 @@ impl CadCommand for MLeaderCommand {
|
||||
Some(preview)
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
matches!(self.step, Step::Layer | Step::PreEnterText)
|
||||
fn input_kind(&self) -> InputKind {
|
||||
if matches!(self.step, Step::Layer | Step::PreEnterText) {
|
||||
InputKind::FreeText
|
||||
} else {
|
||||
InputKind::SingleToken
|
||||
}
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
|
||||
@@ -3,7 +3,7 @@ use acadrust::types::Vector3;
|
||||
use acadrust::MText;
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult, DynField, WorkingPlane};
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult, DynField, InputKind, WorkingPlane};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use crate::t;
|
||||
@@ -243,12 +243,12 @@ impl CadCommand for MTextCommand {
|
||||
matches!(self.step, Step::FirstCorner | Step::OppositeCorner)
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
matches!(self.step, Step::Style)
|
||||
fn input_kind(&self) -> InputKind {
|
||||
if matches!(self.step, Step::Style) {
|
||||
InputKind::FreeText
|
||||
} else {
|
||||
InputKind::SingleToken
|
||||
}
|
||||
}
|
||||
|
||||
fn dyn_field(&self) -> DynField {
|
||||
|
||||
@@ -4,7 +4,7 @@ use acadrust::EntityType;
|
||||
use glam::{DVec3, Vec3};
|
||||
|
||||
use crate::command::{
|
||||
CadCommand, CmdOption, CmdResult, DimensionAssociationInput, WorkingPlane,
|
||||
CadCommand, CmdOption, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane,
|
||||
};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
@@ -139,18 +139,18 @@ impl CadCommand for OrdinateDimCommand {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
true
|
||||
fn input_kind(&self) -> InputKind {
|
||||
if self.awaiting_text {
|
||||
InputKind::FreeText
|
||||
} else {
|
||||
InputKind::SingleToken
|
||||
}
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
!self.awaiting_text && !self.awaiting_angle
|
||||
}
|
||||
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
self.awaiting_text
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
if matches!(self.step, Step::LeaderEndpoint { .. })
|
||||
&& !self.awaiting_text
|
||||
|
||||
@@ -3,7 +3,7 @@ use acadrust::types::{Handle, Vector3};
|
||||
use acadrust::EntityType;
|
||||
|
||||
use crate::command::{
|
||||
CadCommand, CmdOption, CmdResult, DimensionAssociationInput, WorkingPlane,
|
||||
CadCommand, CmdOption, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane,
|
||||
};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
@@ -134,18 +134,18 @@ impl CadCommand for RadiusDimensionCommand {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
true
|
||||
fn input_kind(&self) -> InputKind {
|
||||
if self.awaiting_text {
|
||||
InputKind::FreeText
|
||||
} else {
|
||||
InputKind::SingleToken
|
||||
}
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
!self.awaiting_text && !self.awaiting_angle
|
||||
}
|
||||
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
self.awaiting_text
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
if matches!(self.step, Step::DimLine(_)) && !self.awaiting_text && !self.awaiting_angle {
|
||||
vec![
|
||||
|
||||
@@ -10,7 +10,7 @@ use acadrust::EntityType;
|
||||
use glam::DVec3;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use crate::command::{CadCommand, CmdResult, WorkingPlane};
|
||||
use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use crate::t;
|
||||
@@ -303,15 +303,19 @@ impl CadCommand for TableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
matches!(
|
||||
fn input_kind(&self) -> InputKind {
|
||||
if matches!(
|
||||
self.step,
|
||||
Step::Columns
|
||||
| Step::DataRows { .. }
|
||||
| Step::ColumnWidth { .. }
|
||||
| Step::RowHeight { .. }
|
||||
| Step::InsertionMode { .. }
|
||||
)
|
||||
) {
|
||||
InputKind::SingleToken
|
||||
} else {
|
||||
InputKind::Point
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
@@ -479,6 +483,147 @@ impl CadCommand for TableCommand {
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["TABLE"] });
|
||||
|
||||
/// A table cell resolved from a pick: grid coordinates plus whether its
|
||||
/// content is locked against editing.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct TableCellHit {
|
||||
pub row: usize,
|
||||
pub column: usize,
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
/// Outcome of launching the table cell editor for a pick (double-click or
|
||||
/// TABLEDIT). Distinguishes "cell found but locked" — the caller should
|
||||
/// stop, the indicator is armed — from "nothing to edit" — the caller may
|
||||
/// fall through to other handlers or re-prompt.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum TableCellEditStart {
|
||||
/// Cell editor launched.
|
||||
Started,
|
||||
/// Cell resolved but content-locked/read-only: indicator armed, no
|
||||
/// editor.
|
||||
LockedCell,
|
||||
/// Not a table, or the pick missed the grid.
|
||||
NoCell,
|
||||
}
|
||||
|
||||
impl TableCellHit {
|
||||
/// Flat cell index, matching the Properties palette ordering.
|
||||
pub fn index(&self, table: &acadrust::entities::Table) -> usize {
|
||||
self.row * table.column_count() + self.column
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the table cell under `click_world`. Shared by the double-click
|
||||
/// shortcut and the TABLEDIT command so both agree on grid coordinates,
|
||||
/// flow direction and locked-cell detection. `table_style` is the style
|
||||
/// object the table references, when the caller has the document at hand;
|
||||
/// the table's embedded base style covers the common fallbacks.
|
||||
pub(crate) fn table_cell_at(
|
||||
table: &acadrust::entities::Table,
|
||||
table_style: Option<&acadrust::objects::TableStyle>,
|
||||
click_world: DVec3,
|
||||
) -> Option<TableCellHit> {
|
||||
let horizontal = glam::DVec3::new(
|
||||
table.horizontal_direction.x,
|
||||
table.horizontal_direction.y,
|
||||
table.horizontal_direction.z,
|
||||
)
|
||||
.normalize_or(glam::DVec3::X);
|
||||
let normal = glam::DVec3::new(table.normal.x, table.normal.y, table.normal.z)
|
||||
.normalize_or(glam::DVec3::Z);
|
||||
let mut down = horizontal.cross(normal).normalize_or(glam::DVec3::NEG_Y);
|
||||
if crate::entities::table::resolved_flow_up(table, table_style) {
|
||||
down = -down;
|
||||
}
|
||||
let origin = glam::DVec3::new(
|
||||
table.insertion_point.x,
|
||||
table.insertion_point.y,
|
||||
table.insertion_point.z,
|
||||
);
|
||||
let relative = click_world - origin;
|
||||
let x = relative.dot(horizontal);
|
||||
let y = relative.dot(down);
|
||||
if x < 0.0 || y < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let column = table
|
||||
.columns
|
||||
.iter()
|
||||
.scan(0.0, |offset, column| {
|
||||
*offset += column.width;
|
||||
Some(*offset)
|
||||
})
|
||||
.position(|end| x <= end)?;
|
||||
let row = table
|
||||
.rows
|
||||
.iter()
|
||||
.scan(0.0, |offset, row| {
|
||||
*offset += row.height;
|
||||
Some(*offset)
|
||||
})
|
||||
.position(|end| y <= end)?;
|
||||
Some(TableCellHit {
|
||||
row,
|
||||
column,
|
||||
locked: cell_locked(table, row, column),
|
||||
})
|
||||
}
|
||||
|
||||
/// True when the cell's content may not be edited (locked or read-only).
|
||||
pub(crate) fn cell_locked(table: &acadrust::entities::Table, row: usize, column: usize) -> bool {
|
||||
use acadrust::entities::table::CellStateFlags;
|
||||
table.cell(row, column).is_some_and(|cell| {
|
||||
cell.state
|
||||
.intersects(CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY)
|
||||
})
|
||||
}
|
||||
|
||||
/// TABLEDIT — edit a table cell's text by picking it. The pick only
|
||||
/// collects (table, point); the host resolves the cell and launches
|
||||
/// `TableCellEditCommand`, so the command-first flow and the double-click
|
||||
/// shortcut share one resolver and one editor setup.
|
||||
pub struct TableditCommand;
|
||||
|
||||
impl TableditCommand {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TableditCommand {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for TableditCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"TABLEDIT"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
t!("TABLEDIT Select a table cell:").into_owned()
|
||||
}
|
||||
|
||||
fn on_point(&mut self, point: DVec3) -> CmdResult {
|
||||
CmdResult::EditTableCell {
|
||||
handle: acadrust::Handle::NULL,
|
||||
point,
|
||||
}
|
||||
}
|
||||
|
||||
// A bare Enter at the pick prompt ends the command (AutoCAD leaves the
|
||||
// select-until-valid loop on Enter).
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["TABLEDIT"]
|
||||
});
|
||||
|
||||
pub struct TableCellEditCommand {
|
||||
handle: acadrust::Handle,
|
||||
table: acadrust::entities::Table,
|
||||
@@ -508,7 +653,11 @@ impl CadCommand for TableCellEditCommand {
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
let current = self.table.cell_text(self.row, self.column).unwrap_or("");
|
||||
let current = self
|
||||
.table
|
||||
.cell_text(self.row, self.column)
|
||||
.unwrap_or("")
|
||||
.replace("\\P", "/n");
|
||||
t!(
|
||||
"TABLE CELL Enter text for [%{row},%{column}] <%{current}>:",
|
||||
row = self.row,
|
||||
@@ -518,8 +667,11 @@ impl CadCommand for TableCellEditCommand {
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
true
|
||||
// Cell content is free-form prose ("Foo Bar"): Space must type a
|
||||
// literal space instead of submitting, Enter finishes, and
|
||||
// /n or \n inserts a line break.
|
||||
fn input_kind(&self) -> InputKind {
|
||||
InputKind::FreeText
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _point: DVec3) -> CmdResult {
|
||||
@@ -535,7 +687,11 @@ impl CadCommand for TableCellEditCommand {
|
||||
{
|
||||
return Some(CmdResult::Cancel);
|
||||
}
|
||||
cell.set_text(text);
|
||||
let formatted = text
|
||||
.replace("/n", "\\P")
|
||||
.replace("\\n", "\\P")
|
||||
.replace('\n', "\\P");
|
||||
cell.set_text(&formatted);
|
||||
self.table.block_record_handle = None;
|
||||
Some(CmdResult::ReplaceMany(
|
||||
vec![(self.handle, vec![EntityType::Table(self.table.clone())])],
|
||||
@@ -543,7 +699,126 @@ impl CadCommand for TableCellEditCommand {
|
||||
))
|
||||
}
|
||||
|
||||
// Bare Enter (empty buffer) ends the edit *without* writing — the
|
||||
// host routes an empty submit here instead of on_text_input(""), so
|
||||
// pressing Enter on an untouched cell keeps its current content.
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tabledit_tests {
|
||||
//! `table_cell_at` is the one resolver shared by the double-click
|
||||
//! shortcut and the TABLEDIT pick, so its grid math is pinned here.
|
||||
//! `Table::new` seeds 2.5-unit columns and 0.25-unit rows with the
|
||||
//! default orientation (horizontal +X, flow down).
|
||||
use super::{table_cell_at, TableCellEditCommand, TableCellHit, TableditCommand};
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use acadrust::entities::Table;
|
||||
use acadrust::entities::table::CellStateFlags;
|
||||
use acadrust::types::Vector3;
|
||||
use glam::DVec3;
|
||||
|
||||
fn table_2x2() -> Table {
|
||||
Table::new(Vector3::new(0.0, 0.0, 0.0), 2, 2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_row_and_column_from_point() {
|
||||
let table = table_2x2();
|
||||
// Centers of the four cells.
|
||||
assert_eq!(
|
||||
table_cell_at(&table, None, DVec3::new(1.25, -0.125, 0.0)),
|
||||
Some(TableCellHit {
|
||||
row: 0,
|
||||
column: 0,
|
||||
locked: false
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
table_cell_at(&table, None, DVec3::new(3.75, -0.125, 0.0)),
|
||||
Some(TableCellHit {
|
||||
row: 0,
|
||||
column: 1,
|
||||
locked: false
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
table_cell_at(&table, None, DVec3::new(1.25, -0.375, 0.0)),
|
||||
Some(TableCellHit {
|
||||
row: 1,
|
||||
column: 0,
|
||||
locked: false
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misses_outside_the_grid() {
|
||||
let table = table_2x2();
|
||||
// Before the insertion point, beyond the last column/row, and a
|
||||
// point in the plane's empty space all miss.
|
||||
assert_eq!(
|
||||
table_cell_at(&table, None, DVec3::new(-0.1, -0.1, 0.0)),
|
||||
None
|
||||
);
|
||||
assert_eq!(table_cell_at(&table, None, DVec3::new(6.0, -0.1, 0.0)), None);
|
||||
assert_eq!(table_cell_at(&table, None, DVec3::new(1.0, -9.0, 0.0)), None);
|
||||
assert_eq!(table_cell_at(&table, None, DVec3::new(1.0, 1.0, 0.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_locked_cells() {
|
||||
let mut table = table_2x2();
|
||||
if let Some(cell) = table.cell_mut(0, 1) {
|
||||
cell.state |= CellStateFlags::CONTENT_LOCKED;
|
||||
}
|
||||
let hit = table_cell_at(&table, None, DVec3::new(3.75, -0.125, 0.0))
|
||||
.expect("locked cell still resolves");
|
||||
assert!(hit.locked);
|
||||
// Its neighbour stays editable through the same pick path.
|
||||
assert!(!table_cell_at(&table, None, DVec3::new(1.25, -0.125, 0.0))
|
||||
.expect("neighbour resolves")
|
||||
.locked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabledit_point_hands_point_to_host() {
|
||||
let mut cmd = TableditCommand::new();
|
||||
assert!(!cmd.needs_entity_pick());
|
||||
match cmd.on_point(DVec3::new(1.0, 2.0, 0.0)) {
|
||||
CmdResult::EditTableCell {
|
||||
handle,
|
||||
point,
|
||||
} => {
|
||||
assert_eq!(handle, acadrust::Handle::NULL);
|
||||
assert_eq!(point, DVec3::new(1.0, 2.0, 0.0));
|
||||
}
|
||||
_ => panic!("expected EditTableCell"),
|
||||
}
|
||||
// A bare Enter at the pick prompt ends the command.
|
||||
assert!(matches!(cmd.on_enter(), CmdResult::Cancel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_cell_edit_formats_newlines() {
|
||||
let table = table_2x2();
|
||||
let handle = acadrust::Handle::new(42);
|
||||
let mut edit_cmd = TableCellEditCommand::new(handle, &table, 0, 0);
|
||||
let result = edit_cmd
|
||||
.on_text_input("Line1/nLine2\\nLine3\nLine4")
|
||||
.expect("cell edit commits");
|
||||
match result {
|
||||
CmdResult::ReplaceMany(repl, _) => {
|
||||
assert_eq!(repl.len(), 1);
|
||||
if let acadrust::EntityType::Table(t) = &repl[0].1[0] {
|
||||
assert_eq!(t.cell_text(0, 0).unwrap(), "Line1\\PLine2\\PLine3\\PLine4");
|
||||
} else {
|
||||
panic!("expected Table entity");
|
||||
}
|
||||
}
|
||||
_ => panic!("expected ReplaceMany"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use acadrust::EntityType;
|
||||
use glam::DVec3;
|
||||
use crate::t;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult, WorkingPlane};
|
||||
use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
enum Step {
|
||||
@@ -88,14 +88,12 @@ impl CadCommand for AttdefCommand {
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
!matches!(self.step, Step::Insertion { .. })
|
||||
}
|
||||
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
// Tag must be a single token (so disallow spaces there); the
|
||||
// Prompt and Default value steps are free-form text.
|
||||
matches!(self.step, Step::Prompt { .. } | Step::Default { .. })
|
||||
fn input_kind(&self) -> InputKind {
|
||||
match self.step {
|
||||
Step::Insertion { .. } => InputKind::Point,
|
||||
Step::Tag => InputKind::SingleToken,
|
||||
Step::Prompt { .. } | Step::Default { .. } => InputKind::FreeText,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
|
||||
@@ -4,7 +4,7 @@ use acadrust::EntityType;
|
||||
use glam::{DVec3, Vec3};
|
||||
use crate::t;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult, WorkingPlane};
|
||||
use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
@@ -249,11 +249,11 @@ impl CadCommand for InsertBlockCommand {
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
matches!(
|
||||
self.step,
|
||||
Step::Name | Step::FillAttr { .. } | Step::Point { .. }
|
||||
)
|
||||
fn input_kind(&self) -> InputKind {
|
||||
match self.step {
|
||||
Step::FillAttr { .. } => InputKind::FreeText,
|
||||
Step::Name | Step::Point { .. } => InputKind::SingleToken,
|
||||
}
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
@@ -263,11 +263,6 @@ impl CadCommand for InsertBlockCommand {
|
||||
matches!(self.step, Step::Point { .. }) && self.awaiting.is_none()
|
||||
}
|
||||
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
// Block names don't embed whitespace, but attribute values do.
|
||||
matches!(self.step, Step::FillAttr { .. })
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
match &self.step {
|
||||
Step::Name => {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
// Profile-based kernel solid commands stored as exact ACIS data.
|
||||
|
||||
use acadrust::{entities::Solid3D, EntityType};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use acadrust::{entities::Solid3D, EntityType, Handle};
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::command::{
|
||||
CadCommand, CmdOption, CmdResult, ExtrudeExtent, ExtrudeMode, SelectionEntity,
|
||||
};
|
||||
use crate::scene::WireModel;
|
||||
use crate::t;
|
||||
|
||||
// ── EXTRUDE command ────────────────────────────────────────────────────────
|
||||
@@ -11,32 +16,173 @@ use crate::t;
|
||||
pub struct ExtrudeCommand {
|
||||
command_name: &'static str,
|
||||
step: ExtrudeStep,
|
||||
pub target_handle: acadrust::Handle,
|
||||
handles: Vec<Handle>,
|
||||
preview_profiles: Vec<(Handle, EntityType)>,
|
||||
injected_profile: Option<EntityType>,
|
||||
anchor: DVec3,
|
||||
direction: Option<DVec3>,
|
||||
profile_direction: Option<DVec3>,
|
||||
direction_start: Option<DVec3>,
|
||||
mode: ExtrudeMode,
|
||||
taper_angle: f64,
|
||||
last_height: Option<f64>,
|
||||
taper_return: ExtrudeStep,
|
||||
color: [f32; 4],
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ExtrudeDefaults {
|
||||
mode: ExtrudeMode,
|
||||
height: Option<f64>,
|
||||
taper_angle: f64,
|
||||
}
|
||||
|
||||
fn extrude_defaults() -> &'static Mutex<ExtrudeDefaults> {
|
||||
static DEFAULTS: OnceLock<Mutex<ExtrudeDefaults>> = OnceLock::new();
|
||||
DEFAULTS.get_or_init(|| {
|
||||
Mutex::new(ExtrudeDefaults {
|
||||
mode: ExtrudeMode::Solid,
|
||||
height: None,
|
||||
taper_angle: 0.0,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum ExtrudeStep {
|
||||
Pick,
|
||||
Mode,
|
||||
Height,
|
||||
DirectionStart,
|
||||
DirectionEnd,
|
||||
Path,
|
||||
Taper,
|
||||
Expression,
|
||||
TaperExpression,
|
||||
}
|
||||
|
||||
impl ExtrudeCommand {
|
||||
pub fn new_named(name: &str, color: [f32; 4]) -> Self {
|
||||
let defaults = *extrude_defaults().lock().unwrap_or_else(|error| error.into_inner());
|
||||
Self {
|
||||
command_name: match name {
|
||||
"THICKEN" => "THICKEN",
|
||||
_ => "EXTRUDE",
|
||||
},
|
||||
step: ExtrudeStep::Pick,
|
||||
target_handle: acadrust::Handle::NULL,
|
||||
handles: Vec::new(),
|
||||
preview_profiles: Vec::new(),
|
||||
injected_profile: None,
|
||||
anchor: DVec3::ZERO,
|
||||
direction: None,
|
||||
profile_direction: None,
|
||||
direction_start: None,
|
||||
mode: defaults.mode,
|
||||
taper_angle: defaults.taper_angle,
|
||||
last_height: defaults.height,
|
||||
taper_return: ExtrudeStep::Height,
|
||||
color,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_preselection(
|
||||
&mut self,
|
||||
profiles: Vec<(Handle, EntityType)>,
|
||||
anchor: DVec3,
|
||||
direction: Option<DVec3>,
|
||||
) {
|
||||
self.handles = profiles.iter().map(|(handle, _)| *handle).collect();
|
||||
self.preview_profiles = profiles;
|
||||
self.anchor = anchor;
|
||||
self.profile_direction = direction.and_then(DVec3::try_normalize);
|
||||
if !self.handles.is_empty() {
|
||||
self.step = ExtrudeStep::Height;
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&self, extent: ExtrudeExtent) -> CmdResult {
|
||||
if let Some(height) = match extent {
|
||||
ExtrudeExtent::Height(height) => Some(height),
|
||||
ExtrudeExtent::Direction(direction) => Some(direction.length()),
|
||||
ExtrudeExtent::Path(_) => None,
|
||||
} {
|
||||
extrude_defaults()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.height = Some(height);
|
||||
}
|
||||
CmdResult::ExtrudeEntities {
|
||||
handles: self.handles.clone(),
|
||||
extent,
|
||||
mode: self.mode,
|
||||
taper_angle: self.taper_angle,
|
||||
color: self.color,
|
||||
}
|
||||
}
|
||||
|
||||
fn preview_wires(&self, cursor: DVec3) -> Vec<WireModel> {
|
||||
if self.step != ExtrudeStep::Height {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(axis) = self.profile_direction else {
|
||||
return Vec::new();
|
||||
};
|
||||
let height = (cursor - self.anchor).dot(axis);
|
||||
if !height.is_finite() || height.abs() <= 1e-6 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.preview_profiles
|
||||
.iter()
|
||||
.flat_map(|(_, entity)| {
|
||||
let Some((profile, closed)) =
|
||||
crate::scene::model::sweep_model::extrusion_profile_of(entity)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(normal) = profile.plane.normal() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let direction = [normal[0] * height, normal[1] * height, normal[2] * height];
|
||||
let creates_surface = self.mode == ExtrudeMode::Surface || !closed;
|
||||
let body = if creates_surface {
|
||||
crate::scene::model::sweep_model::extruded_surface(
|
||||
entity,
|
||||
direction,
|
||||
self.taper_angle,
|
||||
)
|
||||
} else {
|
||||
crate::scene::model::sweep_model::extruded_direction(
|
||||
entity,
|
||||
direction,
|
||||
self.taper_angle,
|
||||
)
|
||||
};
|
||||
body.map(|body| preview_body_wires(&body, self.color))
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn preview_body_wires(body: &cadkernel::brep::Body, color: [f32; 4]) -> Vec<WireModel> {
|
||||
let mesh = cadkernel::brep::mesh::tessellate(
|
||||
body,
|
||||
cadkernel::brep::mesh::TessellationTolerance::new(
|
||||
cadkernel::tessellation::DEFAULT_ANGLE,
|
||||
1e-9,
|
||||
),
|
||||
);
|
||||
mesh.edges
|
||||
.into_iter()
|
||||
.filter(|edge| edge.positions.len() >= 2)
|
||||
.map(|edge| {
|
||||
WireModel::solid_f64(
|
||||
"EXTRUDE-PREVIEW".to_owned(),
|
||||
edge.positions,
|
||||
color,
|
||||
false,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl CadCommand for ExtrudeCommand {
|
||||
@@ -45,63 +191,249 @@ impl CadCommand for ExtrudeCommand {
|
||||
}
|
||||
fn prompt(&self) -> String {
|
||||
match self.step {
|
||||
ExtrudeStep::Pick => t!("EXTRUDE Select closed profile (Circle, LwPolyline…):")
|
||||
.into_owned(),
|
||||
ExtrudeStep::Height => t!("EXTRUDE Height:").into_owned(),
|
||||
ExtrudeStep::Pick => t!("EXTRUDE Select objects to extrude or [Mode] (Enter to finish):").into_owned(),
|
||||
ExtrudeStep::Mode => format!(
|
||||
"{} <{}>:",
|
||||
t!("EXTRUDE Creation mode [Solid/Surface]"),
|
||||
if self.mode == ExtrudeMode::Solid { "Solid" } else { "Surface" }
|
||||
),
|
||||
ExtrudeStep::Height => {
|
||||
let base = t!("EXTRUDE Specify height or [Direction/Path/Taper angle/Expression]");
|
||||
self.last_height.map_or_else(
|
||||
|| format!("{base}:"),
|
||||
|height| format!("{base} <{}>:", crate::entities::common::format_length(height)),
|
||||
)
|
||||
}
|
||||
ExtrudeStep::DirectionStart => t!("EXTRUDE Start point of direction:").into_owned(),
|
||||
ExtrudeStep::DirectionEnd => t!("EXTRUDE End point of direction:").into_owned(),
|
||||
ExtrudeStep::Path => t!("EXTRUDE Select extrusion path or [Taper angle]:").into_owned(),
|
||||
ExtrudeStep::Taper => format!(
|
||||
"{} <{}>:",
|
||||
t!("EXTRUDE Specify taper angle or [Expression]"),
|
||||
crate::entities::common::format_angle(self.taper_angle)
|
||||
),
|
||||
ExtrudeStep::Expression => t!("EXTRUDE Enter height expression:").into_owned(),
|
||||
ExtrudeStep::TaperExpression => t!("EXTRUDE Enter taper angle expression:").into_owned(),
|
||||
}
|
||||
}
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
match self.step {
|
||||
ExtrudeStep::Pick => vec![CmdOption::new("Mode", "MODE"), CmdOption::enter("Done")],
|
||||
ExtrudeStep::Mode => vec![
|
||||
CmdOption::new("Solid", "SOLID"),
|
||||
CmdOption::new("Surface", "SURFACE"),
|
||||
],
|
||||
ExtrudeStep::Height => vec![
|
||||
CmdOption::new("Direction", "DIRECTION"),
|
||||
CmdOption::new("Path", "PATH"),
|
||||
CmdOption::new("Taper angle", "TAPER"),
|
||||
CmdOption::new("Expression", "EXPRESSION"),
|
||||
],
|
||||
ExtrudeStep::Path => vec![CmdOption::new("Taper angle", "TAPER")],
|
||||
ExtrudeStep::Taper => vec![CmdOption::new("Expression", "EXPRESSION")],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
self.step == ExtrudeStep::Pick
|
||||
self.step == ExtrudeStep::Path
|
||||
}
|
||||
fn entity_pick_uses_surface_point(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn set_entity_pick_direction(&mut self, direction: Option<DVec3>) {
|
||||
self.direction = direction.and_then(DVec3::try_normalize);
|
||||
if self.step == ExtrudeStep::Pick {
|
||||
self.profile_direction = direction.and_then(DVec3::try_normalize);
|
||||
}
|
||||
}
|
||||
fn on_entity_pick(&mut self, handle: acadrust::Handle, point: DVec3) -> CmdResult {
|
||||
fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult {
|
||||
if handle.is_null() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
self.target_handle = handle;
|
||||
self.anchor = point;
|
||||
self.step = ExtrudeStep::Height;
|
||||
CmdResult::NeedPoint
|
||||
match self.step {
|
||||
ExtrudeStep::Pick => {
|
||||
if !self.handles.contains(&handle) {
|
||||
self.handles.push(handle);
|
||||
self.anchor = point;
|
||||
if let Some(entity) = self.injected_profile.take() {
|
||||
self.preview_profiles.push((handle, entity));
|
||||
}
|
||||
} else {
|
||||
self.injected_profile = None;
|
||||
}
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
ExtrudeStep::Path if !self.handles.contains(&handle) => {
|
||||
self.finish(ExtrudeExtent::Path(handle))
|
||||
}
|
||||
_ => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
if self.step == ExtrudeStep::Height {
|
||||
let height = self
|
||||
.direction
|
||||
.map(|direction| (pt - self.anchor).dot(direction))
|
||||
.unwrap_or_else(|| pt.distance(self.anchor));
|
||||
if !height.is_finite() || height.abs() <= 1e-6 {
|
||||
return CmdResult::NeedPoint;
|
||||
match self.step {
|
||||
ExtrudeStep::Height => {
|
||||
let height = self
|
||||
.profile_direction
|
||||
.map(|direction| (pt - self.anchor).dot(direction))
|
||||
.unwrap_or_else(|| pt.distance(self.anchor));
|
||||
if height.is_finite() && height.abs() > 1e-6 {
|
||||
self.finish(ExtrudeExtent::Height(height))
|
||||
} else {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
return CmdResult::ExtrudeEntity {
|
||||
handle: self.target_handle,
|
||||
height,
|
||||
color: self.color,
|
||||
};
|
||||
ExtrudeStep::DirectionStart => {
|
||||
self.direction_start = Some(pt);
|
||||
self.step = ExtrudeStep::DirectionEnd;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
ExtrudeStep::DirectionEnd => {
|
||||
let direction = pt - self.direction_start.unwrap_or(pt);
|
||||
if direction.is_finite() && direction.length_squared() > 1e-12 {
|
||||
self.finish(ExtrudeExtent::Direction(direction))
|
||||
} else {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
_ => CmdResult::NeedPoint,
|
||||
}
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn wants_text_input(&self) -> bool {
|
||||
self.step == ExtrudeStep::Height
|
||||
matches!(
|
||||
self.step,
|
||||
ExtrudeStep::Pick
|
||||
| ExtrudeStep::Mode
|
||||
| ExtrudeStep::Height
|
||||
| ExtrudeStep::Path
|
||||
| ExtrudeStep::Taper
|
||||
| ExtrudeStep::Expression
|
||||
| ExtrudeStep::TaperExpression
|
||||
)
|
||||
}
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
matches!(self.step, ExtrudeStep::Height | ExtrudeStep::Path)
|
||||
}
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
crate::entities::common::parse_typed_length(text)
|
||||
.filter(|&h| h.abs() > 1e-6)
|
||||
.map(|h| CmdResult::ExtrudeEntity {
|
||||
handle: self.target_handle,
|
||||
height: h,
|
||||
color: self.color,
|
||||
})
|
||||
let value = text.trim();
|
||||
// A bare Enter finalizes the current source selection through
|
||||
// `on_enter`. Treating the empty string as an option prefix makes it
|
||||
// match every keyword and silently advances to the wrong branch.
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match self.step {
|
||||
ExtrudeStep::Pick if "MODE".starts_with(&value.to_ascii_uppercase()) => {
|
||||
self.step = ExtrudeStep::Mode;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
ExtrudeStep::Mode => {
|
||||
let upper = value.to_ascii_uppercase();
|
||||
if "SOLID".starts_with(&upper) {
|
||||
self.mode = ExtrudeMode::Solid;
|
||||
} else if "SURFACE".starts_with(&upper) {
|
||||
self.mode = ExtrudeMode::Surface;
|
||||
} else {
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
extrude_defaults()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.mode = self.mode;
|
||||
self.step = ExtrudeStep::Pick;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
ExtrudeStep::Height => {
|
||||
let upper = value.to_ascii_uppercase();
|
||||
if "DIRECTION".starts_with(&upper) {
|
||||
self.step = ExtrudeStep::DirectionStart;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
if "PATH".starts_with(&upper) {
|
||||
self.step = ExtrudeStep::Path;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
if "TAPER".starts_with(&upper) || "TAPER ANGLE".starts_with(&upper) {
|
||||
self.taper_return = ExtrudeStep::Height;
|
||||
self.step = ExtrudeStep::Taper;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
if "EXPRESSION".starts_with(&upper) {
|
||||
self.step = ExtrudeStep::Expression;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
crate::entities::common::parse_typed_length(value)
|
||||
.filter(|height| height.is_finite() && height.abs() > 1e-6)
|
||||
.map(|height| self.finish(ExtrudeExtent::Height(height)))
|
||||
}
|
||||
ExtrudeStep::Path => {
|
||||
let upper = value.to_ascii_uppercase();
|
||||
if "TAPER".starts_with(&upper) || "TAPER ANGLE".starts_with(&upper) {
|
||||
self.taper_return = ExtrudeStep::Path;
|
||||
self.step = ExtrudeStep::Taper;
|
||||
}
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
ExtrudeStep::Taper => {
|
||||
if "EXPRESSION".starts_with(&value.to_ascii_uppercase()) {
|
||||
self.step = ExtrudeStep::TaperExpression;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
let angle = crate::entities::common::parse_angle(value)?;
|
||||
if !angle.is_finite() || angle.abs() >= std::f64::consts::FRAC_PI_2 {
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
self.taper_angle = angle;
|
||||
extrude_defaults()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.taper_angle = angle;
|
||||
self.step = self.taper_return;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
ExtrudeStep::Expression => crate::app::expr_eval::eval_number(value)
|
||||
.filter(|height| height.is_finite() && height.abs() > 1e-6)
|
||||
.map(|height| self.finish(ExtrudeExtent::Height(height))),
|
||||
ExtrudeStep::TaperExpression => {
|
||||
let angle = crate::app::expr_eval::eval_number(value)
|
||||
.and_then(|value| crate::entities::common::parse_angle(&value.to_string()))?;
|
||||
if !angle.is_finite() || angle.abs() >= std::f64::consts::FRAC_PI_2 {
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
self.taper_angle = angle;
|
||||
extrude_defaults()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.taper_angle = angle;
|
||||
self.step = self.taper_return;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
match self.step {
|
||||
ExtrudeStep::Pick if !self.handles.is_empty() => {
|
||||
self.step = ExtrudeStep::Height;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
ExtrudeStep::Mode => {
|
||||
self.step = ExtrudeStep::Pick;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
ExtrudeStep::Height => self
|
||||
.last_height
|
||||
.filter(|height| height.is_finite() && height.abs() > 1e-6)
|
||||
.map(|height| self.finish(ExtrudeExtent::Height(height)))
|
||||
.unwrap_or(CmdResult::Cancel),
|
||||
ExtrudeStep::Taper => {
|
||||
self.step = self.taper_return;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
fn cursor_axis(&self) -> Option<(DVec3, DVec3)> {
|
||||
(self.step == ExtrudeStep::Height).then_some((self.anchor, self.direction?))
|
||||
(self.step == ExtrudeStep::Height)
|
||||
.then_some((self.anchor, self.profile_direction?))
|
||||
}
|
||||
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
|
||||
(self.step == ExtrudeStep::Height).then_some(crate::command::DynSpec {
|
||||
@@ -114,7 +446,46 @@ impl CadCommand for ExtrudeCommand {
|
||||
})
|
||||
}
|
||||
fn dyn_live_value(&self, cursor: DVec3) -> Option<f64> {
|
||||
Some((cursor - self.anchor).dot(self.direction?))
|
||||
Some((cursor - self.anchor).dot(self.profile_direction?))
|
||||
}
|
||||
fn is_selection_gathering(&self) -> bool {
|
||||
self.step == ExtrudeStep::Pick
|
||||
}
|
||||
fn selection_forces_add(&self) -> bool {
|
||||
self.step == ExtrudeStep::Pick
|
||||
}
|
||||
fn inject_selection_entities(&mut self, entities: Vec<SelectionEntity>) {
|
||||
if self.step != ExtrudeStep::Pick {
|
||||
return;
|
||||
}
|
||||
self.handles = entities.iter().map(|entry| entry.handle).collect();
|
||||
self.preview_profiles = entities
|
||||
.iter()
|
||||
.map(|entry| (entry.handle, entry.entity.clone()))
|
||||
.collect();
|
||||
if let Some(curve) = entities
|
||||
.iter()
|
||||
.find_map(|entry| crate::entities::curve::entity_curve(&entry.entity))
|
||||
{
|
||||
self.anchor = DVec3::from_array(curve.plane.origin);
|
||||
self.profile_direction = curve.plane.normal().map(DVec3::from_array);
|
||||
} else {
|
||||
self.profile_direction = None;
|
||||
}
|
||||
}
|
||||
fn on_selection_complete(&mut self, _handles: Vec<Handle>) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn inject_before_entity_pick(&self) -> bool {
|
||||
self.step == ExtrudeStep::Pick
|
||||
}
|
||||
fn inject_picked_entity(&mut self, entity: EntityType) {
|
||||
if self.step == ExtrudeStep::Pick {
|
||||
self.injected_profile = Some(entity);
|
||||
}
|
||||
}
|
||||
fn on_preview_wires(&mut self, cursor: DVec3) -> Vec<WireModel> {
|
||||
self.preview_wires(cursor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,6 +850,24 @@ pub fn empty_solid3d() -> EntityType {
|
||||
EntityType::Solid3D(Solid3D::new())
|
||||
}
|
||||
|
||||
pub fn empty_extruded_surface(direction: DVec3, taper_angle: f64) -> EntityType {
|
||||
use acadrust::entities::{Surface, SurfaceData, SurfaceKind};
|
||||
use acadrust::types::Vector3;
|
||||
|
||||
let mut surface = Surface::new(SurfaceKind::Extruded);
|
||||
if let SurfaceData::Extruded {
|
||||
options,
|
||||
sweep_vector,
|
||||
..
|
||||
} = &mut surface.surface_data
|
||||
{
|
||||
options.draft_angle = taper_angle;
|
||||
options.is_solid = false;
|
||||
*sweep_vector = Vector3::new(direction.x, direction.y, direction.z);
|
||||
}
|
||||
EntityType::Surface(surface)
|
||||
}
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["EXTRUDE", "THICKEN", "PRESSPULL"]
|
||||
|
||||
@@ -6,7 +6,7 @@ use acadrust::types::{Vector2, Vector3};
|
||||
use acadrust::{EntityType, Handle};
|
||||
use crate::t;
|
||||
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult};
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind};
|
||||
use crate::modules::draw::draw::polyline::{
|
||||
arc_sample_points, compute_bulge, seg_exit_tangent, update_tangent_after_arc,
|
||||
};
|
||||
@@ -464,14 +464,16 @@ impl CadCommand for MviewCommand {
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
self.step == Step::RectangleFirst
|
||||
|| self.step == Step::ChooseView
|
||||
fn input_kind(&self) -> InputKind {
|
||||
if self.step == Step::ChooseView {
|
||||
InputKind::FreeText
|
||||
} else if self.step == Step::RectangleFirst
|
||||
|| (self.step == Step::Polygon && !self.polygon.is_empty())
|
||||
}
|
||||
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
self.step == Step::ChooseView
|
||||
{
|
||||
InputKind::SingleToken
|
||||
} else {
|
||||
InputKind::Point
|
||||
}
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use acadrust::entities::acis::{SabReader, SatBody, SatDocument};
|
||||
use acadrust::entities::{Body, Region, Solid3D};
|
||||
use acadrust::entities::{Body, Region, Solid3D, Surface};
|
||||
use cadkernel::brep::{self, Body as KernelBody};
|
||||
|
||||
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
|
||||
@@ -156,10 +156,18 @@ fn parse_acis(
|
||||
}
|
||||
|
||||
pub fn kernel_body(solid: &Solid3D) -> Option<KernelBody> {
|
||||
kernel_acis_body(&solid.acis_data)
|
||||
}
|
||||
|
||||
pub fn kernel_surface_body(surface: &Surface) -> Option<KernelBody> {
|
||||
kernel_acis_body(&surface.acis_data)
|
||||
}
|
||||
|
||||
fn kernel_acis_body(acis: &acadrust::entities::AcisData) -> Option<KernelBody> {
|
||||
let sat = parse_acis(
|
||||
|| solid.parse_sat(),
|
||||
solid.acis_data.is_binary,
|
||||
&solid.acis_data.sab_data,
|
||||
|| acis.parse(),
|
||||
acis.is_binary,
|
||||
&acis.sab_data,
|
||||
)?;
|
||||
let (mut bodies, loss) = cadkernel::acis::lift(&sat);
|
||||
if !loss.is_empty() || bodies.len() != 1 {
|
||||
|
||||
@@ -602,6 +602,9 @@ impl Scene {
|
||||
Some(EntityType::Solid3D(solid)) => {
|
||||
crate::scene::convert::solid3d_tess::kernel_body(solid)
|
||||
}
|
||||
Some(EntityType::Surface(surface)) => {
|
||||
crate::scene::convert::solid3d_tess::kernel_surface_body(surface)
|
||||
}
|
||||
_ => None,
|
||||
})?;
|
||||
Some((handle, body))
|
||||
|
||||
+69
-6
@@ -2725,12 +2725,21 @@ impl Scene {
|
||||
if let Some((mut set, wires, center)) =
|
||||
crate::scene::model::solid_model::display_from_solid(&solid, color)
|
||||
{
|
||||
if let Some(EntityType::Solid3D(entity)) = self.document.get_entity_mut(handle) {
|
||||
entity.point_of_reference = acadrust::types::Vector3::new(
|
||||
center[0], center[1], center[2],
|
||||
);
|
||||
entity.wires = wires;
|
||||
entity.silhouettes.clear();
|
||||
if let Some(entity) = self.document.get_entity_mut(handle) {
|
||||
let reference = acadrust::types::Vector3::new(center[0], center[1], center[2]);
|
||||
match entity {
|
||||
EntityType::Solid3D(entity) => {
|
||||
entity.point_of_reference = reference;
|
||||
entity.wires = wires;
|
||||
entity.silhouettes.clear();
|
||||
}
|
||||
EntityType::Surface(entity) => {
|
||||
entity.point_of_reference = reference;
|
||||
entity.wires = wires;
|
||||
entity.silhouettes.clear();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let name = handle.value().to_string();
|
||||
for mesh in &mut set.lods {
|
||||
@@ -8206,6 +8215,60 @@ impl Scene {
|
||||
)
|
||||
}
|
||||
|
||||
/// Normal object selection for shaded 3D bodies is edge based. This keeps
|
||||
/// the broad face interior available to modelling commands without making
|
||||
/// it part of ordinary object picking.
|
||||
pub fn solid_edge_click_hit(
|
||||
&self,
|
||||
cursor: iced::Point,
|
||||
view_rot: glam::Mat4,
|
||||
eye: glam::DVec3,
|
||||
bounds: iced::Rectangle,
|
||||
candidate_handles: Option<&HashSet<Handle>>,
|
||||
tolerance_px: f32,
|
||||
) -> Option<Handle> {
|
||||
let meshes = self.interaction_meshes_arc();
|
||||
pick::hit_test::mesh_edge_click_hit(
|
||||
cursor,
|
||||
meshes.iter().filter_map(|set| {
|
||||
let handle = set.entity_handle()?;
|
||||
if candidate_handles.is_some_and(|handles| !handles.contains(&handle)) {
|
||||
return None;
|
||||
}
|
||||
let (edges, edges_low) = set.geometry_edges();
|
||||
(!edges.is_empty()).then_some((
|
||||
handle,
|
||||
edges,
|
||||
edges_low,
|
||||
set.instance_transform,
|
||||
))
|
||||
}),
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
tolerance_px,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn solid_edge_hover_hit(
|
||||
&self,
|
||||
cursor: iced::Point,
|
||||
view_rot: glam::Mat4,
|
||||
eye: glam::DVec3,
|
||||
bounds: iced::Rectangle,
|
||||
candidate_handles: Option<&HashSet<Handle>>,
|
||||
tolerance_px: f32,
|
||||
) -> Option<Handle> {
|
||||
self.solid_edge_click_hit(
|
||||
cursor,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
candidate_handles,
|
||||
tolerance_px,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn solid_click_point_for(
|
||||
&self,
|
||||
cursor: iced::Point,
|
||||
|
||||
@@ -274,6 +274,16 @@ impl MeshLodSet {
|
||||
.map_or(self.lods.as_slice(), |source| source.lods.as_slice())
|
||||
}
|
||||
|
||||
pub fn geometry_edges(&self) -> (&[[f32; 3]], &[[f32; 3]]) {
|
||||
self.instance_source.as_ref().map_or(
|
||||
(self.edge_verts.as_slice(), self.edge_verts_low.as_slice()),
|
||||
|source| (
|
||||
source.edge_verts.as_slice(),
|
||||
source.edge_verts_low.as_slice(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn entity_handle(&self) -> Option<acadrust::Handle> {
|
||||
self.instance_handle.or_else(|| {
|
||||
self.lods
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use acadrust::entities::{EmbeddedEntity, Solid3D};
|
||||
use acadrust::objects::{
|
||||
DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryCone,
|
||||
SolidHistoryCylinder, SolidHistoryNodeBase, SolidHistoryOperation,
|
||||
SolidHistoryCylinder, SolidHistoryLoft, SolidHistoryNodeBase, SolidHistoryOperation,
|
||||
SolidHistoryPyramid, SolidHistoryRevolve, SolidHistorySphere, SolidHistorySweep,
|
||||
SolidHistoryTorus,
|
||||
};
|
||||
@@ -25,9 +25,13 @@ pub const GRIP_SIDES: usize = 10_007;
|
||||
pub const GRIP_MAJOR_RADIUS: usize = 10_008;
|
||||
pub const GRIP_MINOR_RADIUS: usize = 10_009;
|
||||
pub const GRIP_TOP_RADIUS: usize = 10_010;
|
||||
pub const GRIP_POSITION: usize = 10_011;
|
||||
pub const GRIP_REVOLVE_AXIS: usize = 10_012;
|
||||
pub const GRIP_DRAFT: usize = 10_012;
|
||||
pub const GRIP_PROFILE_FIRST: usize = 10_200;
|
||||
pub const GRIP_SWEEP_PROFILE_FIRST: usize = 20_000;
|
||||
pub const GRIP_SWEEP_PATH_FIRST: usize = 30_000;
|
||||
pub const GRIP_LOFT_SECTION_FIRST: usize = 40_000;
|
||||
pub const GRIP_REVOLVE_PROFILE_FIRST: usize = 50_000;
|
||||
pub const GRIP_REVOLVE_AXIS: usize = 10_013;
|
||||
pub const GRIP_BOX_CORNER_FIRST: usize = 10_100;
|
||||
pub const GRIP_BOX_FACE_X_MIN: usize = 10_110;
|
||||
pub const GRIP_BOX_FACE_X_MAX: usize = 10_111;
|
||||
@@ -62,6 +66,8 @@ pub const PROP_BANK: &str = "solid_history_bank";
|
||||
pub const PROP_TWIST_ALONG_PATH: &str = "solid_history_twist_along_path";
|
||||
pub const PROP_SCALE_ALONG_PATH: &str = "solid_history_scale_along_path";
|
||||
pub const PROP_SWEEP_LENGTH: &str = "solid_history_sweep_length";
|
||||
pub const PROP_EXTRUSION_HEIGHT: &str = "solid_history_extrusion_height";
|
||||
pub const PROP_TAPER_ANGLE: &str = "solid_history_taper_angle";
|
||||
pub const PROP_PYRAMID_TYPE: &str = "solid_history_pyramid_type";
|
||||
pub const PROP_HISTORY: &str = "solid_history_record";
|
||||
pub const PROP_SHOW_HISTORY: &str = "solid_history_show";
|
||||
@@ -115,6 +121,7 @@ pub fn has_specialized_primitive_properties(
|
||||
| SolidHistoryOperation::Torus(_)
|
||||
| SolidHistoryOperation::Pyramid(_)
|
||||
| SolidHistoryOperation::Sweep(_)
|
||||
| SolidHistoryOperation::Extrusion(_)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -130,7 +137,7 @@ pub fn reference_point(operation: &SolidHistoryOperation) -> Option<glam::DVec3>
|
||||
}
|
||||
SolidHistoryOperation::Cone(value) => world_point(value.base.transform, [0.0; 3]),
|
||||
SolidHistoryOperation::Cylinder(value) => world_point(value.base.transform, [0.0; 3]),
|
||||
SolidHistoryOperation::Sweep(value) => world_point(
|
||||
SolidHistoryOperation::Sweep(value) | SolidHistoryOperation::Extrusion(value) => world_point(
|
||||
value.base.transform,
|
||||
[
|
||||
value.reference_point.x,
|
||||
@@ -144,6 +151,100 @@ pub fn reference_point(operation: &SolidHistoryOperation) -> Option<glam::DVec3>
|
||||
}
|
||||
}
|
||||
|
||||
fn extrusion_properties(
|
||||
document: &acadrust::CadDocument,
|
||||
handle: acadrust::Handle,
|
||||
value: &SolidHistorySweep,
|
||||
) -> Vec<PropSection> {
|
||||
let position = world_point(
|
||||
value.base.transform,
|
||||
[
|
||||
value.reference_point.x,
|
||||
value.reference_point.y,
|
||||
value.reference_point.z,
|
||||
],
|
||||
)
|
||||
.unwrap_or(glam::DVec3::ZERO);
|
||||
let (record_history, object_show_history, show_history_mode) =
|
||||
history_flags(document, handle).unwrap_or((false, false, 1));
|
||||
let (show_history, show_history_editable) =
|
||||
displayed_history_state(object_show_history, show_history_mode);
|
||||
let path_length = sweep_path_length(value);
|
||||
let height_value = path_length.unwrap_or(value.end_draft_distance);
|
||||
let height = if value.path_entity.is_some() {
|
||||
PropValue::ReadOnly(crate::entities::common::format_length(height_value))
|
||||
} else {
|
||||
PropValue::EditText(crate::entities::common::format_length(height_value))
|
||||
};
|
||||
let taper = if value.path_entity.is_some() {
|
||||
PropValue::ReadOnly(crate::entities::common::format_angle(value.draft_angle))
|
||||
} else {
|
||||
PropValue::EditText(crate::entities::common::format_angle(value.draft_angle))
|
||||
};
|
||||
vec![
|
||||
PropSection {
|
||||
title: t!("Geometry").into_owned(),
|
||||
props: vec![
|
||||
Property {
|
||||
label: t!("Solid type").into_owned(),
|
||||
field: "solid_history_type",
|
||||
value: PropValue::ReadOnly(t!("Extrusion").into_owned()),
|
||||
},
|
||||
crate::entities::common::edit_prop(
|
||||
t!("Position X").as_ref(),
|
||||
PROP_POSITION_X,
|
||||
position.x,
|
||||
),
|
||||
crate::entities::common::edit_prop(
|
||||
t!("Position Y").as_ref(),
|
||||
PROP_POSITION_Y,
|
||||
position.y,
|
||||
),
|
||||
crate::entities::common::edit_prop(
|
||||
t!("Position Z").as_ref(),
|
||||
PROP_POSITION_Z,
|
||||
position.z,
|
||||
),
|
||||
Property {
|
||||
label: t!("Height").into_owned(),
|
||||
field: PROP_EXTRUSION_HEIGHT,
|
||||
value: height,
|
||||
},
|
||||
Property {
|
||||
label: t!("Taper angle").into_owned(),
|
||||
field: PROP_TAPER_ANGLE,
|
||||
value: taper,
|
||||
},
|
||||
],
|
||||
},
|
||||
PropSection {
|
||||
title: t!("Solid History").into_owned(),
|
||||
props: vec![
|
||||
Property {
|
||||
label: t!("History").into_owned(),
|
||||
field: PROP_HISTORY,
|
||||
value: PropValue::Choice {
|
||||
selected: if record_history { "Record" } else { "None" }.to_string(),
|
||||
options: vec!["None".to_string(), "Record".to_string()],
|
||||
},
|
||||
},
|
||||
Property {
|
||||
label: t!("Show History").into_owned(),
|
||||
field: PROP_SHOW_HISTORY,
|
||||
value: if show_history_editable {
|
||||
PropValue::Choice {
|
||||
selected: if show_history { "Yes" } else { "No" }.to_string(),
|
||||
options: vec!["No".to_string(), "Yes".to_string()],
|
||||
}
|
||||
} else {
|
||||
PropValue::ReadOnly(if show_history { "Yes" } else { "No" }.to_string())
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn sweep_properties(
|
||||
document: &acadrust::CadDocument,
|
||||
handle: acadrust::Handle,
|
||||
@@ -231,7 +332,30 @@ fn sweep_path_length(value: &SolidHistorySweep) -> Option<f64> {
|
||||
use acadrust::entities::EmbeddedEntity;
|
||||
use acadrust::EntityType;
|
||||
|
||||
let entity = match value.path_entity.as_ref()? {
|
||||
let path_entity = value.path_entity.as_ref()?;
|
||||
if let EmbeddedEntity::Spline(path) = path_entity {
|
||||
if path.degree == 1 && path.control_points.len() >= 2 && path.weights.is_empty() {
|
||||
let transform = glam::DMat4::from_cols_array(&value.path_entity_transform);
|
||||
if !transform.is_finite() || transform.determinant().abs() <= 1e-12 {
|
||||
return None;
|
||||
}
|
||||
return Some(
|
||||
path.control_points
|
||||
.windows(2)
|
||||
.map(|pair| {
|
||||
let start = transform.transform_point3(glam::DVec3::new(
|
||||
pair[0].x, pair[0].y, pair[0].z,
|
||||
));
|
||||
let end = transform.transform_point3(glam::DVec3::new(
|
||||
pair[1].x, pair[1].y, pair[1].z,
|
||||
));
|
||||
end.distance(start)
|
||||
})
|
||||
.sum(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let entity = match path_entity {
|
||||
EmbeddedEntity::Line(value) => EntityType::Line(value.clone()),
|
||||
EmbeddedEntity::Arc(value) => EntityType::Arc(value.clone()),
|
||||
EmbeddedEntity::Circle(value) => EntityType::Circle(value.clone()),
|
||||
@@ -894,6 +1018,9 @@ pub fn primitive_properties(
|
||||
SolidHistoryOperation::Pyramid(value) => pyramid_properties(document, handle, value),
|
||||
SolidHistoryOperation::Torus(value) => torus_properties(document, handle, value),
|
||||
SolidHistoryOperation::Sweep(value) => sweep_properties(document, handle, value),
|
||||
SolidHistoryOperation::Extrusion(value) => {
|
||||
extrusion_properties(document, handle, value)
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -924,6 +1051,8 @@ pub fn is_primitive_property(field: &str) -> bool {
|
||||
| PROP_PROFILE_ROTATION
|
||||
| PROP_TWIST_ALONG_PATH
|
||||
| PROP_SCALE_ALONG_PATH
|
||||
| PROP_EXTRUSION_HEIGHT
|
||||
| PROP_TAPER_ANGLE
|
||||
| PROP_PYRAMID_TYPE
|
||||
)
|
||||
}
|
||||
@@ -938,6 +1067,81 @@ pub fn is_specialized_property(field: &str) -> bool {
|
||||
|| is_history_choice(field)
|
||||
}
|
||||
|
||||
fn apply_extrusion_geometry_property(
|
||||
value: &mut SolidHistorySweep,
|
||||
field: &str,
|
||||
text: &str,
|
||||
) -> Option<bool> {
|
||||
if value.path_entity.is_some()
|
||||
&& matches!(field, PROP_EXTRUSION_HEIGHT | PROP_TAPER_ANGLE)
|
||||
{
|
||||
return Some(false);
|
||||
}
|
||||
match field {
|
||||
PROP_POSITION_X | PROP_POSITION_Y | PROP_POSITION_Z => {
|
||||
let axis = match field {
|
||||
PROP_POSITION_X => 0,
|
||||
PROP_POSITION_Y => 1,
|
||||
_ => 2,
|
||||
};
|
||||
let target = crate::entities::common::parse_length(text)?;
|
||||
if !target.is_finite() {
|
||||
return Some(false);
|
||||
}
|
||||
let current = matrix(value.base.transform)?;
|
||||
let reference = current.transform_point3(glam::DVec3::new(
|
||||
value.reference_point.x,
|
||||
value.reference_point.y,
|
||||
value.reference_point.z,
|
||||
));
|
||||
if !reference.is_finite() || (target - reference[axis]).abs() <= 1e-12 {
|
||||
return Some(false);
|
||||
}
|
||||
let mut delta = glam::DVec3::ZERO;
|
||||
delta[axis] = target - reference[axis];
|
||||
let updated = glam::DMat4::from_translation(delta) * current;
|
||||
if !updated.is_finite() || updated.determinant().abs() <= 1e-12 {
|
||||
return Some(false);
|
||||
}
|
||||
value.base.transform = updated.to_cols_array();
|
||||
Some(true)
|
||||
}
|
||||
PROP_EXTRUSION_HEIGHT => {
|
||||
let height = crate::entities::common::parse_length(text)?;
|
||||
if !height.is_finite() || height <= 1e-9 {
|
||||
return Some(false);
|
||||
}
|
||||
let direction = glam::DVec3::new(
|
||||
value.direction.x,
|
||||
value.direction.y,
|
||||
value.direction.z,
|
||||
);
|
||||
let Some(unit) = direction.try_normalize() else {
|
||||
return Some(false);
|
||||
};
|
||||
let updated = unit * height;
|
||||
if (updated - direction).length_squared() <= 1e-24 {
|
||||
return Some(false);
|
||||
}
|
||||
value.direction = acadrust::types::Vector3::new(updated.x, updated.y, updated.z);
|
||||
value.end_draft_distance = height;
|
||||
Some(true)
|
||||
}
|
||||
PROP_TAPER_ANGLE => {
|
||||
let angle = crate::entities::common::parse_angle(text)?;
|
||||
if !angle.is_finite()
|
||||
|| angle.abs() >= std::f64::consts::FRAC_PI_2
|
||||
|| (angle - value.draft_angle).abs() <= 1e-12
|
||||
{
|
||||
return Some(false);
|
||||
}
|
||||
value.draft_angle = angle;
|
||||
Some(true)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_history_choice(
|
||||
document: &mut acadrust::CadDocument,
|
||||
handle: acadrust::Handle,
|
||||
@@ -1367,6 +1571,13 @@ pub fn apply_primitive_property(
|
||||
return applied;
|
||||
}
|
||||
}
|
||||
if let SolidHistoryOperation::Extrusion(extrusion_value) = operation {
|
||||
if let Some(applied) =
|
||||
apply_extrusion_geometry_property(extrusion_value, field, value)
|
||||
{
|
||||
return applied;
|
||||
}
|
||||
}
|
||||
if let SolidHistoryOperation::Torus(torus_value) = operation {
|
||||
if let Some(applied) = apply_torus_position_property(torus_value, field, value) {
|
||||
return applied;
|
||||
@@ -1676,6 +1887,268 @@ fn into_embedded_entity(value: EntityType) -> Option<EmbeddedEntity> {
|
||||
})
|
||||
}
|
||||
|
||||
fn profile_matrix(value: &SolidHistorySweep) -> Option<glam::DMat4> {
|
||||
Some(matrix(value.base.transform)? * matrix(value.sweep_entity_transform)?)
|
||||
}
|
||||
|
||||
fn extrusion_profile_grips(value: &SolidHistorySweep) -> (Vec<GripDef>, Option<glam::DVec3>) {
|
||||
let Some(profile) = value.sweep_entity.as_ref().and_then(embedded_entity) else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
let Some(transform) = profile_matrix(value) else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
let source = profile.grips();
|
||||
if source.is_empty() {
|
||||
return (Vec::new(), None);
|
||||
}
|
||||
|
||||
let mut center = glam::DVec3::ZERO;
|
||||
let mut count = 0.0;
|
||||
let grips = source
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, grip)| {
|
||||
let world = transform.transform_point3(grip.world);
|
||||
if !world.is_finite() {
|
||||
return None;
|
||||
}
|
||||
center += world;
|
||||
count += 1.0;
|
||||
let transform_vector = |vector: glam::DVec3| {
|
||||
let vector = transform.transform_vector3(vector);
|
||||
(vector.length_squared() > 1e-12).then(|| vector.normalize())
|
||||
};
|
||||
Some(GripDef {
|
||||
id: GRIP_PROFILE_FIRST + index,
|
||||
world,
|
||||
is_midpoint: grip.is_midpoint,
|
||||
shape: grip.shape,
|
||||
dir: grip.dir.and_then(transform_vector),
|
||||
axis: grip.axis.and_then(transform_vector),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let center = (count > 0.0).then_some(center / count);
|
||||
(grips, center)
|
||||
}
|
||||
|
||||
fn apply_extrusion_profile_grip(
|
||||
value: &mut SolidHistorySweep,
|
||||
index: usize,
|
||||
apply: GripApply,
|
||||
) -> bool {
|
||||
let Some(mut profile) = value.sweep_entity.as_ref().and_then(embedded_entity) else {
|
||||
return false;
|
||||
};
|
||||
let Some(source_grip) = profile.grips().get(index).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let Some(transform) = profile_matrix(value) else {
|
||||
return false;
|
||||
};
|
||||
let inverse = transform.inverse();
|
||||
if !inverse.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let local_apply = match apply {
|
||||
GripApply::Absolute(world) => {
|
||||
let local = inverse.transform_point3(world);
|
||||
if !local.is_finite() {
|
||||
return false;
|
||||
}
|
||||
GripApply::Absolute(local)
|
||||
}
|
||||
GripApply::Translate(world_delta) => {
|
||||
let local_delta = inverse.transform_vector3(world_delta);
|
||||
if !local_delta.is_finite() || local_delta.length_squared() <= 1e-24 {
|
||||
return false;
|
||||
}
|
||||
GripApply::Translate(local_delta)
|
||||
}
|
||||
};
|
||||
profile.apply_grip(source_grip.id, local_apply);
|
||||
let Some(profile) = into_embedded_entity(profile) else {
|
||||
return false;
|
||||
};
|
||||
value.sweep_entity = Some(profile);
|
||||
true
|
||||
}
|
||||
|
||||
fn sweep_embedded_grips(
|
||||
value: Option<&EmbeddedEntity>,
|
||||
base_transform: [f64; 16],
|
||||
entity_transform: [f64; 16],
|
||||
first_id: usize,
|
||||
) -> Vec<GripDef> {
|
||||
let Some(entity) = value.and_then(embedded_entity) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let (Some(base), Some(entity_placement)) =
|
||||
(matrix(base_transform), matrix(entity_transform))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let transform = base * entity_placement;
|
||||
entity
|
||||
.grips()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, grip)| {
|
||||
let world = transform.transform_point3(grip.world);
|
||||
if !world.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let transform_vector = |vector: glam::DVec3| {
|
||||
let vector = transform.transform_vector3(vector);
|
||||
(vector.length_squared() > 1e-12).then(|| vector.normalize())
|
||||
};
|
||||
Some(GripDef {
|
||||
id: first_id + index,
|
||||
world,
|
||||
is_midpoint: grip.is_midpoint,
|
||||
shape: grip.shape,
|
||||
dir: grip.dir.and_then(transform_vector),
|
||||
axis: grip.axis.and_then(transform_vector),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn apply_sweep_embedded_grip(
|
||||
value: &mut Option<EmbeddedEntity>,
|
||||
base_transform: [f64; 16],
|
||||
entity_transform: [f64; 16],
|
||||
index: usize,
|
||||
apply: GripApply,
|
||||
) -> bool {
|
||||
let Some(mut entity) = value.as_ref().and_then(embedded_entity) else {
|
||||
return false;
|
||||
};
|
||||
let Some(source_grip) = entity.grips().get(index).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let (Some(base), Some(entity_placement)) =
|
||||
(matrix(base_transform), matrix(entity_transform))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let inverse = (base * entity_placement).inverse();
|
||||
if !inverse.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let apply = match apply {
|
||||
GripApply::Absolute(world) => {
|
||||
let local = inverse.transform_point3(world);
|
||||
if !local.is_finite() {
|
||||
return false;
|
||||
}
|
||||
GripApply::Absolute(local)
|
||||
}
|
||||
GripApply::Translate(world_delta) => {
|
||||
let local_delta = inverse.transform_vector3(world_delta);
|
||||
if !local_delta.is_finite() || local_delta.length_squared() <= 1e-24 {
|
||||
return false;
|
||||
}
|
||||
GripApply::Translate(local_delta)
|
||||
}
|
||||
};
|
||||
entity.apply_grip(source_grip.id, apply);
|
||||
let Some(entity) = into_embedded_entity(entity) else {
|
||||
return false;
|
||||
};
|
||||
*value = Some(entity);
|
||||
true
|
||||
}
|
||||
|
||||
fn loft_section_grips(value: &SolidHistoryLoft) -> Vec<GripDef> {
|
||||
if !value.guides.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(transform) = matrix(value.base.transform) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut result = Vec::new();
|
||||
let mut index = 0;
|
||||
for section in &value.cross_sections {
|
||||
let Some(entity) = embedded_entity(section) else {
|
||||
continue;
|
||||
};
|
||||
for source in entity.grips() {
|
||||
let id = GRIP_LOFT_SECTION_FIRST + index;
|
||||
index += 1;
|
||||
let world = transform.transform_point3(source.world);
|
||||
if !world.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let transform_vector = |vector: glam::DVec3| {
|
||||
let vector = transform.transform_vector3(vector);
|
||||
(vector.length_squared() > 1e-12).then(|| vector.normalize())
|
||||
};
|
||||
result.push(GripDef {
|
||||
id,
|
||||
world,
|
||||
is_midpoint: source.is_midpoint,
|
||||
shape: source.shape,
|
||||
dir: source.dir.and_then(transform_vector),
|
||||
axis: source.axis.and_then(transform_vector),
|
||||
});
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn apply_loft_section_grip(
|
||||
value: &mut SolidHistoryLoft,
|
||||
mut index: usize,
|
||||
apply: GripApply,
|
||||
) -> bool {
|
||||
if !value.guides.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some(transform) = matrix(value.base.transform) else {
|
||||
return false;
|
||||
};
|
||||
let inverse = transform.inverse();
|
||||
if !inverse.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let local_apply = match apply {
|
||||
GripApply::Absolute(world) => {
|
||||
let local = inverse.transform_point3(world);
|
||||
if !local.is_finite() {
|
||||
return false;
|
||||
}
|
||||
GripApply::Absolute(local)
|
||||
}
|
||||
GripApply::Translate(world_delta) => {
|
||||
let local_delta = inverse.transform_vector3(world_delta);
|
||||
if !local_delta.is_finite() || local_delta.length_squared() <= 1e-24 {
|
||||
return false;
|
||||
}
|
||||
GripApply::Translate(local_delta)
|
||||
}
|
||||
};
|
||||
for section in &mut value.cross_sections {
|
||||
let Some(mut entity) = embedded_entity(section) else {
|
||||
continue;
|
||||
};
|
||||
let grips = entity.grips();
|
||||
if index >= grips.len() {
|
||||
index -= grips.len();
|
||||
continue;
|
||||
}
|
||||
let grip_id = grips[index].id;
|
||||
entity.apply_grip(grip_id, local_apply);
|
||||
let Some(entity) = into_embedded_entity(entity) else {
|
||||
return false;
|
||||
};
|
||||
*section = entity;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn revolve_profile_matrix(value: &SolidHistoryRevolve) -> Option<glam::DMat4> {
|
||||
let base = matrix(value.base.transform)?;
|
||||
let axis_point = glam::DVec3::new(
|
||||
@@ -1684,8 +2157,8 @@ fn revolve_profile_matrix(value: &SolidHistoryRevolve) -> Option<glam::DMat4> {
|
||||
value.axis_point.z,
|
||||
);
|
||||
let axis = glam::DVec3::new(value.direction.x, value.direction.y, value.direction.z)
|
||||
.normalize_or_zero();
|
||||
if !axis_point.is_finite() || axis.length_squared() <= 1e-12 {
|
||||
.try_normalize()?;
|
||||
if !axis_point.is_finite() || !value.start_angle.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
@@ -1770,6 +2243,96 @@ fn apply_revolve_profile_grip(
|
||||
true
|
||||
}
|
||||
|
||||
fn extrusion_draft_grip(
|
||||
value: &SolidHistorySweep,
|
||||
) -> Option<(glam::DVec3, glam::DVec3, glam::DVec3, f64, f64)> {
|
||||
if value.path_entity.is_some() {
|
||||
return None;
|
||||
}
|
||||
let (profile_grips, center) = extrusion_profile_grips(value);
|
||||
let center = center?;
|
||||
let transform = profile_matrix(value)?;
|
||||
let inverse = transform.inverse();
|
||||
if !inverse.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let profile = value.sweep_entity.as_ref().and_then(embedded_entity)?;
|
||||
let normal = crate::entities::curve::entity_curve(&profile)?.plane.normal()?;
|
||||
let normal = transform
|
||||
.transform_vector3(glam::DVec3::from_array(normal))
|
||||
.try_normalize()?;
|
||||
let center_local = inverse.transform_point3(center);
|
||||
let anchor = profile_grips
|
||||
.into_iter()
|
||||
.filter_map(|grip| {
|
||||
let local = inverse.transform_point3(grip.world);
|
||||
let radius = (grip.world - center).length_squared();
|
||||
(local.is_finite() && radius > 1e-12).then_some((grip.world, local))
|
||||
})
|
||||
.min_by(|(_, a), (_, b)| {
|
||||
a.x.total_cmp(&b.x)
|
||||
.then_with(|| {
|
||||
(a.y - center_local.y)
|
||||
.abs()
|
||||
.total_cmp(&(b.y - center_local.y).abs())
|
||||
})
|
||||
.then_with(|| {
|
||||
(a.z - center_local.z)
|
||||
.abs()
|
||||
.total_cmp(&(b.z - center_local.z).abs())
|
||||
})
|
||||
})?
|
||||
.0;
|
||||
let radial = anchor - center;
|
||||
let radial_length = radial.length();
|
||||
if !radial_length.is_finite() || radial_length <= 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let radial = radial / radial_length;
|
||||
let base = matrix(value.base.transform)?;
|
||||
let direction = base.transform_vector3(glam::DVec3::new(
|
||||
value.direction.x,
|
||||
value.direction.y,
|
||||
value.direction.z,
|
||||
));
|
||||
let height = direction.dot(normal).abs();
|
||||
if !direction.is_finite() || !height.is_finite() || height <= 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let draft_offset = -height * value.draft_angle.tan();
|
||||
if !draft_offset.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let base_top = anchor + direction;
|
||||
let world = base_top + radial * draft_offset;
|
||||
world
|
||||
.is_finite()
|
||||
.then_some((world, base_top, radial, radial_length, height))
|
||||
}
|
||||
|
||||
fn apply_extrusion_draft_grip(value: &mut SolidHistorySweep, apply: GripApply) -> bool {
|
||||
let GripApply::Absolute(world) = apply else {
|
||||
return false;
|
||||
};
|
||||
let Some((_, base_top, radial, profile_radius, height)) = extrusion_draft_grip(value) else {
|
||||
return false;
|
||||
};
|
||||
let top_offset = (world - base_top).dot(radial);
|
||||
if !top_offset.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let limit = 89.0_f64.to_radians();
|
||||
let inward_limit = ((profile_radius - 1e-6).max(1e-6) / height)
|
||||
.atan()
|
||||
.min(limit);
|
||||
let angle = (-top_offset).atan2(height).clamp(-limit, inward_limit);
|
||||
if !angle.is_finite() || (angle - value.draft_angle).abs() <= 1e-12 {
|
||||
return false;
|
||||
}
|
||||
value.draft_angle = angle;
|
||||
true
|
||||
}
|
||||
|
||||
fn grip(
|
||||
id: usize,
|
||||
world: glam::DVec3,
|
||||
@@ -1991,17 +2554,21 @@ pub fn primitive_grips(
|
||||
None,
|
||||
);
|
||||
}
|
||||
SolidHistoryOperation::Sweep(value) => add(
|
||||
GRIP_POSITION,
|
||||
value.base.transform,
|
||||
[
|
||||
value.reference_point.x,
|
||||
value.reference_point.y,
|
||||
value.reference_point.z,
|
||||
],
|
||||
GripShape::Square,
|
||||
None,
|
||||
),
|
||||
SolidHistoryOperation::Sweep(value) => {
|
||||
grips.extend(sweep_embedded_grips(
|
||||
value.sweep_entity.as_ref(),
|
||||
value.base.transform,
|
||||
value.sweep_entity_transform,
|
||||
GRIP_SWEEP_PROFILE_FIRST,
|
||||
));
|
||||
grips.extend(sweep_embedded_grips(
|
||||
value.path_entity.as_ref(),
|
||||
value.base.transform,
|
||||
value.path_entity_transform,
|
||||
GRIP_SWEEP_PATH_FIRST,
|
||||
));
|
||||
}
|
||||
SolidHistoryOperation::Loft(value) => grips.extend(loft_section_grips(value)),
|
||||
SolidHistoryOperation::Revolve(value) => {
|
||||
add(
|
||||
GRIP_REVOLVE_AXIS,
|
||||
@@ -2012,6 +2579,32 @@ pub fn primitive_grips(
|
||||
);
|
||||
grips.extend(revolve_profile_grips(value));
|
||||
}
|
||||
SolidHistoryOperation::Extrusion(value) => {
|
||||
let (profile_grips, profile_center) = extrusion_profile_grips(value);
|
||||
grips.extend(profile_grips);
|
||||
let direction = [value.direction.x, value.direction.y, value.direction.z];
|
||||
if value.path_entity.is_none() {
|
||||
if let (Some(center), Some(base)) = (profile_center, matrix(value.base.transform)) {
|
||||
let direction = base.transform_vector3(glam::DVec3::from_array(direction));
|
||||
if direction.length_squared() > 1e-12 {
|
||||
grips.push(grip(
|
||||
GRIP_HEIGHT,
|
||||
center + direction,
|
||||
GripShape::Triangle,
|
||||
Some(direction.normalize()),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some((world, _, radial, _, _)) = extrusion_draft_grip(value) {
|
||||
grips.push(grip(
|
||||
GRIP_DRAFT,
|
||||
world,
|
||||
GripShape::Triangle,
|
||||
Some(radial),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
grips
|
||||
@@ -2027,6 +2620,42 @@ pub fn apply_primitive_grip(
|
||||
return apply_revolve_profile_grip(value, index, apply);
|
||||
}
|
||||
}
|
||||
if let SolidHistoryOperation::Loft(value) = operation {
|
||||
if let Some(index) = grip_id.checked_sub(GRIP_LOFT_SECTION_FIRST) {
|
||||
return apply_loft_section_grip(value, index, apply);
|
||||
}
|
||||
}
|
||||
if let SolidHistoryOperation::Sweep(value) = operation {
|
||||
if let Some(index) = grip_id.checked_sub(GRIP_SWEEP_PATH_FIRST) {
|
||||
return apply_sweep_embedded_grip(
|
||||
&mut value.path_entity,
|
||||
value.base.transform,
|
||||
value.path_entity_transform,
|
||||
index,
|
||||
apply,
|
||||
);
|
||||
}
|
||||
if let Some(index) = grip_id
|
||||
.checked_sub(GRIP_SWEEP_PROFILE_FIRST)
|
||||
.filter(|_| grip_id < GRIP_SWEEP_PATH_FIRST)
|
||||
{
|
||||
return apply_sweep_embedded_grip(
|
||||
&mut value.sweep_entity,
|
||||
value.base.transform,
|
||||
value.sweep_entity_transform,
|
||||
index,
|
||||
apply,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let SolidHistoryOperation::Extrusion(value) = operation {
|
||||
if let Some(index) = grip_id.checked_sub(GRIP_PROFILE_FIRST) {
|
||||
return apply_extrusion_profile_grip(value, index, apply);
|
||||
}
|
||||
if grip_id == GRIP_DRAFT {
|
||||
return apply_extrusion_draft_grip(value, apply);
|
||||
}
|
||||
}
|
||||
let GripApply::Absolute(world) = apply else {
|
||||
return false;
|
||||
};
|
||||
@@ -2160,25 +2789,44 @@ pub fn apply_primitive_grip(
|
||||
}
|
||||
_ => return false,
|
||||
},
|
||||
SolidHistoryOperation::Sweep(value) if grip_id == GRIP_POSITION => {
|
||||
let Some(current) = matrix(value.base.transform) else {
|
||||
return false;
|
||||
};
|
||||
let reference = current.transform_point3(glam::DVec3::new(
|
||||
value.reference_point.x,
|
||||
value.reference_point.y,
|
||||
value.reference_point.z,
|
||||
));
|
||||
let delta = world - reference;
|
||||
if !delta.is_finite() || delta.length_squared() <= 1e-24 {
|
||||
return false;
|
||||
}
|
||||
value.base.transform =
|
||||
(glam::DMat4::from_translation(delta) * current).to_cols_array();
|
||||
}
|
||||
SolidHistoryOperation::Revolve(value) if grip_id == GRIP_REVOLVE_AXIS => {
|
||||
value.axis_point = acadrust::types::Vector3::new(local.x, local.y, local.z);
|
||||
}
|
||||
SolidHistoryOperation::Extrusion(value) => match grip_id {
|
||||
GRIP_HEIGHT => {
|
||||
if value.path_entity.is_some() {
|
||||
return false;
|
||||
}
|
||||
let (_, Some(profile_center)) = extrusion_profile_grips(value) else {
|
||||
return false;
|
||||
};
|
||||
let Some(current) = matrix(value.base.transform) else {
|
||||
return false;
|
||||
};
|
||||
let direction = current
|
||||
.inverse()
|
||||
.transform_vector3(world - profile_center);
|
||||
let height = direction.length();
|
||||
if !direction.is_finite() || !height.is_finite() || height <= 1e-6 {
|
||||
return false;
|
||||
}
|
||||
let current = glam::DVec3::new(
|
||||
value.direction.x,
|
||||
value.direction.y,
|
||||
value.direction.z,
|
||||
);
|
||||
if (direction - current).length_squared() <= 1e-24 {
|
||||
return false;
|
||||
}
|
||||
value.direction = acadrust::types::Vector3::new(
|
||||
direction.x,
|
||||
direction.y,
|
||||
direction.z,
|
||||
);
|
||||
value.end_draft_distance = height;
|
||||
}
|
||||
_ => return false,
|
||||
},
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
|
||||
+466
-42
@@ -3,9 +3,9 @@
|
||||
use cadkernel::brep::{self, Body};
|
||||
use cadkernel::geom2d::{Arc, Curve, EllipseArc, Line};
|
||||
use cadkernel::space::{PlanarCurve, Plane, Vec3};
|
||||
use acadrust::entities::{EmbeddedEntity, LwPolyline, LwVertex};
|
||||
use acadrust::entities::{EmbeddedEntity, LwPolyline, LwVertex, Spline};
|
||||
use acadrust::objects::{
|
||||
SolidHistoryNodeBase, SolidHistoryOperation, SolidHistoryRevolve, SolidHistorySweep,
|
||||
SolidHistoryLoft, SolidHistoryNodeBase, SolidHistoryOperation, SolidHistorySweep,
|
||||
};
|
||||
use acadrust::types::{Vector2, Vector3};
|
||||
use acadrust::EntityType;
|
||||
@@ -28,30 +28,185 @@ pub struct Profile {
|
||||
/// is handed over as the arcs it is made of rather than as one curve with
|
||||
/// nowhere for the sweep to start.
|
||||
pub fn profile_of(entity: &EntityType) -> Option<Profile> {
|
||||
let planar: PlanarCurve = entity_curve(entity)?;
|
||||
let planar = entity_curve(entity).or_else(|| planar_polygon_entity(entity))?;
|
||||
if !planar.curve.is_closed() {
|
||||
return None;
|
||||
}
|
||||
let pieces = match &planar.curve {
|
||||
Curve::Polyline(_) => planar.curve.segments(),
|
||||
Curve::Circle(circle) => quarters(circle.centre, circle.radius),
|
||||
Curve::Arc(arc) => split_arc(*arc),
|
||||
Curve::Ellipse(arc) => split_ellipse(*arc),
|
||||
Curve::Nurbs(curve) => (0..4)
|
||||
.map(|part| {
|
||||
curve
|
||||
.trimmed(part as f64 / 4.0, (part + 1) as f64 / 4.0)
|
||||
.map(Curve::Nurbs)
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?,
|
||||
_ => return None,
|
||||
};
|
||||
let pieces = profile_pieces(&planar.curve, true)?;
|
||||
(pieces.len() >= 3).then_some(Profile {
|
||||
plane: planar.plane,
|
||||
pieces,
|
||||
})
|
||||
}
|
||||
|
||||
/// An entity-level planar curve suitable for either a solid profile or an
|
||||
/// open surface profile.
|
||||
pub fn extrusion_profile_of(entity: &EntityType) -> Option<(Profile, bool)> {
|
||||
let planar = entity_curve(entity).or_else(|| planar_polygon_entity(entity))?;
|
||||
let closed = planar.curve.is_closed();
|
||||
let pieces = profile_pieces(&planar.curve, closed)?;
|
||||
(!pieces.is_empty()).then_some((
|
||||
Profile {
|
||||
plane: planar.plane,
|
||||
pieces,
|
||||
},
|
||||
closed,
|
||||
))
|
||||
}
|
||||
|
||||
fn profile_pieces(curve: &Curve, closed: bool) -> Option<Vec<Curve>> {
|
||||
let pieces = match curve {
|
||||
Curve::Polyline(_) => curve.segments(),
|
||||
Curve::Circle(circle) => quarters(circle.centre, circle.radius),
|
||||
Curve::Arc(arc) if closed => split_arc(*arc),
|
||||
Curve::Arc(arc) => vec![Curve::Arc(*arc)],
|
||||
Curve::Ellipse(arc) if closed => split_ellipse(*arc),
|
||||
Curve::Ellipse(arc) => vec![Curve::Ellipse(*arc)],
|
||||
Curve::Line(line) => vec![Curve::Line(*line)],
|
||||
Curve::Nurbs(nurbs) if closed => (0..4)
|
||||
.map(|part| {
|
||||
nurbs
|
||||
.trimmed(part as f64 / 4.0, (part + 1) as f64 / 4.0)
|
||||
.map(Curve::Nurbs)
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?,
|
||||
Curve::Nurbs(nurbs) => vec![Curve::Nurbs(nurbs.clone())],
|
||||
_ => return None,
|
||||
};
|
||||
Some(pieces)
|
||||
}
|
||||
|
||||
fn planar_polygon_entity(entity: &EntityType) -> Option<PlanarCurve> {
|
||||
let (points, closed) = match entity {
|
||||
EntityType::Polyline3D(value) => (
|
||||
value
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z])
|
||||
.collect::<Vec<_>>(),
|
||||
value.is_closed(),
|
||||
),
|
||||
EntityType::Face3D(value) => (
|
||||
value
|
||||
.corners()
|
||||
.into_iter()
|
||||
.map(|point| [point.x, point.y, point.z])
|
||||
.collect::<Vec<_>>(),
|
||||
true,
|
||||
),
|
||||
EntityType::Solid(value) => (
|
||||
value
|
||||
.boundary_corners()
|
||||
.into_iter()
|
||||
.map(|point| [point.x, point.y, point.z])
|
||||
.collect::<Vec<_>>(),
|
||||
true,
|
||||
),
|
||||
EntityType::Region(value) => (
|
||||
value
|
||||
.wires
|
||||
.first()?
|
||||
.points
|
||||
.iter()
|
||||
.map(|point| [point.x, point.y, point.z])
|
||||
.collect::<Vec<_>>(),
|
||||
true,
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
let mut points = points;
|
||||
if closed && points.len() > 2 && points.first() == points.last() {
|
||||
points.pop();
|
||||
}
|
||||
if points.len() < if closed { 3 } else { 2 } {
|
||||
return None;
|
||||
}
|
||||
let origin = Vec3::from(points[0]);
|
||||
let first = Vec3::from(points[1]) - origin;
|
||||
let normal = points[2..]
|
||||
.iter()
|
||||
.find_map(|point| first.cross(Vec3::from(*point) - origin).normalize())
|
||||
.or_else(|| {
|
||||
(!closed).then(|| first.cross(Vec3::Z).normalize().unwrap_or(Vec3::Y))
|
||||
})?;
|
||||
let plane = Plane::orthonormal(points[0], first.to_array(), normal.to_array())?;
|
||||
let scale = points
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|value| value.abs())
|
||||
.fold(1.0_f64, f64::max);
|
||||
if points
|
||||
.iter()
|
||||
.any(|point| !plane.contains(*point, scale * 1e-9))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let vertices = points
|
||||
.iter()
|
||||
.map(|point| {
|
||||
plane.project(*point).map(|position| cadkernel::geom2d::PolylineVertex {
|
||||
position,
|
||||
bulge: 0.0,
|
||||
})
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some(PlanarCurve::new(
|
||||
plane,
|
||||
Curve::Polyline(cadkernel::geom2d::Polyline { vertices, closed }),
|
||||
))
|
||||
}
|
||||
|
||||
fn region_profiles(entity: &EntityType) -> Option<(Plane, Vec<Vec<Curve>>)> {
|
||||
let EntityType::Region(region) = entity else {
|
||||
return None;
|
||||
};
|
||||
let plane = planar_polygon_entity(entity)?.plane;
|
||||
let mut profiles = Vec::with_capacity(region.wires.len());
|
||||
for wire in ®ion.wires {
|
||||
let mut points = wire
|
||||
.points
|
||||
.iter()
|
||||
.map(|point| [point.x, point.y, point.z])
|
||||
.collect::<Vec<_>>();
|
||||
if points.len() > 2 && points.first() == points.last() {
|
||||
points.pop();
|
||||
}
|
||||
if points.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let scale = points
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|value| value.abs())
|
||||
.fold(1.0_f64, f64::max);
|
||||
if points
|
||||
.iter()
|
||||
.any(|point| !plane.contains(*point, scale * 1e-9))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let vertices = points
|
||||
.iter()
|
||||
.map(|point| {
|
||||
plane
|
||||
.project(*point)
|
||||
.map(|position| cadkernel::geom2d::PolylineVertex {
|
||||
position,
|
||||
bulge: 0.0,
|
||||
})
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
profiles.push(
|
||||
Curve::Polyline(cadkernel::geom2d::Polyline {
|
||||
vertices,
|
||||
closed: true,
|
||||
})
|
||||
.segments(),
|
||||
);
|
||||
}
|
||||
(!profiles.is_empty()).then_some((plane, profiles))
|
||||
}
|
||||
|
||||
/// A circle as four arcs.
|
||||
fn quarters(centre: [f64; 2], radius: f64) -> Vec<Curve> {
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
@@ -103,10 +258,112 @@ fn split_ellipse(arc: EllipseArc) -> Vec<Curve> {
|
||||
pub fn extruded(entity: &EntityType, height: f64) -> Option<Body> {
|
||||
let profile = profile_of(entity)?;
|
||||
let normal = profile.plane.normal()?;
|
||||
brep::extrude(
|
||||
extruded_direction(
|
||||
entity,
|
||||
[normal[0] * height, normal[1] * height, normal[2] * height],
|
||||
0.0,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn extruded_direction(
|
||||
entity: &EntityType,
|
||||
direction: [f64; 3],
|
||||
taper_angle: f64,
|
||||
) -> Option<Body> {
|
||||
if let Some((plane, profiles)) =
|
||||
region_profiles(entity).filter(|(_, profiles)| profiles.len() > 1)
|
||||
{
|
||||
if taper_angle.abs() > 1e-12 {
|
||||
return None;
|
||||
}
|
||||
return brep::extrude_region(plane, &profiles, direction);
|
||||
}
|
||||
let profile = profile_of(entity)?;
|
||||
brep::extrude_tapered(profile.plane, &profile.pieces, direction, taper_angle)
|
||||
}
|
||||
|
||||
pub fn extruded_surface(
|
||||
entity: &EntityType,
|
||||
direction: [f64; 3],
|
||||
taper_angle: f64,
|
||||
) -> Option<Body> {
|
||||
if matches!(entity, EntityType::Region(region) if region.wires.len() > 1) {
|
||||
return None;
|
||||
}
|
||||
let (profile, _) = extrusion_profile_of(entity)?;
|
||||
brep::extrude_surface_tapered(
|
||||
profile.plane,
|
||||
&profile.pieces,
|
||||
[normal[0] * height, normal[1] * height, normal[2] * height],
|
||||
direction,
|
||||
taper_angle,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn extruded_along_path(
|
||||
entity: &EntityType,
|
||||
path: &EntityType,
|
||||
taper_angle: f64,
|
||||
) -> Option<Body> {
|
||||
if matches!(entity, EntityType::Region(region) if region.wires.len() > 1) {
|
||||
if taper_angle.abs() > 1e-12 {
|
||||
return None;
|
||||
}
|
||||
return extruded_direction(entity, straight_path_direction(path)?, 0.0);
|
||||
}
|
||||
let profile = profile_of(entity)?;
|
||||
if let EntityType::Polyline3D(path) = path {
|
||||
let points = path
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z])
|
||||
.collect::<Vec<_>>();
|
||||
if taper_angle.abs() > 1e-12 {
|
||||
if points.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
return brep::extrude_tapered(
|
||||
profile.plane,
|
||||
&profile.pieces,
|
||||
(Vec3::from(points[1]) - Vec3::from(points[0])).to_array(),
|
||||
taper_angle,
|
||||
);
|
||||
}
|
||||
return brep::sweep_along_polyline3d(profile.plane, &profile.pieces, &points);
|
||||
}
|
||||
if taper_angle.abs() <= 1e-12 {
|
||||
return swept(entity, path);
|
||||
}
|
||||
let curve = entity_curve(path)?;
|
||||
let Curve::Line(line) = curve.curve else {
|
||||
return None;
|
||||
};
|
||||
let direction = Vec3::from(curve.plane.point_at(line.end))
|
||||
- Vec3::from(curve.plane.point_at(line.start));
|
||||
brep::extrude_tapered(
|
||||
profile.plane,
|
||||
&profile.pieces,
|
||||
direction.to_array(),
|
||||
taper_angle,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn straight_path_direction(path: &EntityType) -> Option<[f64; 3]> {
|
||||
if let EntityType::Polyline3D(path) = path {
|
||||
if path.vertices.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
let start = &path.vertices[0].position;
|
||||
let end = &path.vertices[1].position;
|
||||
return Some([end.x - start.x, end.y - start.y, end.z - start.z]);
|
||||
}
|
||||
let curve = entity_curve(path)?;
|
||||
let Curve::Line(line) = curve.curve else {
|
||||
return None;
|
||||
};
|
||||
Some(
|
||||
(Vec3::from(curve.plane.point_at(line.end))
|
||||
- Vec3::from(curve.plane.point_at(line.start)))
|
||||
.to_array(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,26 +396,13 @@ pub fn revolved(
|
||||
)
|
||||
}
|
||||
|
||||
fn profile_bounds_center(profile: &Profile) -> Option<Vec3> {
|
||||
let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
|
||||
let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
let mut found = false;
|
||||
for piece in &profile.pieces {
|
||||
for point in piece.tessellate_within(1e-4) {
|
||||
let point = Vec3::from(profile.plane.point_at(point));
|
||||
if !point.is_finite() {
|
||||
continue;
|
||||
}
|
||||
low.x = low.x.min(point.x);
|
||||
low.y = low.y.min(point.y);
|
||||
low.z = low.z.min(point.z);
|
||||
high.x = high.x.max(point.x);
|
||||
high.y = high.y.max(point.y);
|
||||
high.z = high.z.max(point.z);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
found.then(|| (low + high) * 0.5)
|
||||
fn profile_center(profile: &Profile) -> Option<Vec3> {
|
||||
let count = profile.pieces.len();
|
||||
let sum = profile.pieces.iter().try_fold(Vec3::ZERO, |sum, piece| {
|
||||
let point = Vec3::from(profile.plane.point_at(piece.point_at(0.0)));
|
||||
point.is_finite().then_some(sum + point)
|
||||
})?;
|
||||
(count > 0).then_some(sum / count as f64)
|
||||
}
|
||||
|
||||
/// Stores the source profile and canonical axis required to rebuild and edit
|
||||
@@ -175,7 +419,7 @@ pub fn revolve_history(
|
||||
let profile = profile_of(entity)?;
|
||||
let from = Vec3::from(from);
|
||||
let direction = (Vec3::from(to) - from).normalize()?;
|
||||
let center = profile_bounds_center(&profile)?;
|
||||
let center = profile_center(&profile)?;
|
||||
let axis_point = from + direction * (center - from).dot(direction);
|
||||
let mut base = SolidHistoryNodeBase::new(1);
|
||||
base.transform = glam::DMat4::IDENTITY.to_cols_array();
|
||||
@@ -194,13 +438,40 @@ pub fn revolve_history(
|
||||
/// SWEEP as an exact B-rep along straight and circular path pieces.
|
||||
pub fn swept(profile: &EntityType, path: &EntityType) -> Option<Body> {
|
||||
let profile = profile_of(profile)?;
|
||||
let path = entity_curve(path)?;
|
||||
let mut path = entity_curve(path)?;
|
||||
let pieces = match &path.curve {
|
||||
Curve::Line(line) => vec![Curve::Line(*line)],
|
||||
Curve::Arc(arc) => vec![Curve::Arc(*arc)],
|
||||
Curve::Circle(circle) => quarters(circle.centre, circle.radius),
|
||||
Curve::Polyline(_) => path.curve.segments(),
|
||||
Curve::Ellipse(_) | Curve::Nurbs(_) => {
|
||||
let scale = path
|
||||
.curve
|
||||
.point_at(0.0)
|
||||
.into_iter()
|
||||
.chain(path.curve.point_at(1.0))
|
||||
.map(f64::abs)
|
||||
.fold(1.0_f64, f64::max);
|
||||
path.curve
|
||||
.tessellate_within(scale * 1e-4)
|
||||
.windows(2)
|
||||
.filter(|pair| pair[0] != pair[1])
|
||||
.map(|pair| Curve::Line(Line {
|
||||
start: pair[0],
|
||||
end: pair[1],
|
||||
}))
|
||||
.collect()
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let profile_center = profile
|
||||
.pieces
|
||||
.iter()
|
||||
.map(|piece| Vec3::from(profile.plane.point_at(piece.point_at(0.0))))
|
||||
.fold(Vec3::ZERO, |sum, point| sum + point)
|
||||
/ profile.pieces.len() as f64;
|
||||
let path_start = Vec3::from(path.plane.point_at(path.curve.point_at(0.0)));
|
||||
path.plane.origin = (Vec3::from(path.plane.origin) + profile_center - path_start).to_array();
|
||||
brep::sweep_along(profile.plane, &profile.pieces, path.plane, &pieces)
|
||||
}
|
||||
|
||||
@@ -219,6 +490,26 @@ pub fn lofted(profiles: &[EntityType]) -> Option<Body> {
|
||||
brep::loft(§ions)
|
||||
}
|
||||
|
||||
/// Stores the source sections required to rebuild and edit a loft.
|
||||
pub fn loft_history(profiles: &[EntityType]) -> Option<SolidHistoryOperation> {
|
||||
let cross_sections = profiles
|
||||
.iter()
|
||||
.map(embedded_path)
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
if cross_sections.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut base = SolidHistoryNodeBase::new(1);
|
||||
base.transform = glam::DMat4::IDENTITY.to_cols_array();
|
||||
Some(SolidHistoryOperation::Loft(SolidHistoryLoft {
|
||||
base,
|
||||
operation_major: 1,
|
||||
cross_sections,
|
||||
guides: Vec::new(),
|
||||
..SolidHistoryLoft::default()
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PolysolidJustification {
|
||||
Left,
|
||||
@@ -226,7 +517,7 @@ pub enum PolysolidJustification {
|
||||
Right,
|
||||
}
|
||||
|
||||
fn embedded_path(entity: &EntityType) -> Option<EmbeddedEntity> {
|
||||
pub fn embedded_path(entity: &EntityType) -> Option<EmbeddedEntity> {
|
||||
match entity {
|
||||
EntityType::Line(value) => Some(EmbeddedEntity::Line(value.clone())),
|
||||
EntityType::Arc(value) => Some(EmbeddedEntity::Arc(value.clone())),
|
||||
@@ -234,10 +525,143 @@ fn embedded_path(entity: &EntityType) -> Option<EmbeddedEntity> {
|
||||
EntityType::Ellipse(value) => Some(EmbeddedEntity::Ellipse(value.clone())),
|
||||
EntityType::LwPolyline(value) => Some(EmbeddedEntity::LwPolyline(value.clone())),
|
||||
EntityType::Spline(value) => Some(EmbeddedEntity::Spline(value.clone())),
|
||||
EntityType::Polyline3D(value) if !value.is_closed() && value.vertices.len() >= 2 => {
|
||||
let mut spline = Spline::from_control_points(
|
||||
1,
|
||||
value
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|vertex| vertex.position)
|
||||
.collect(),
|
||||
);
|
||||
spline.flags.linear = true;
|
||||
spline.flags.planar = false;
|
||||
Some(EmbeddedEntity::Spline(spline))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn embedded_planar_entity(entity: &EntityType) -> Option<(EmbeddedEntity, [f64; 16])> {
|
||||
if matches!(entity, EntityType::Region(region) if region.wires.len() > 1) {
|
||||
return None;
|
||||
}
|
||||
if !matches!(entity, EntityType::Polyline3D(_)) {
|
||||
if let Some(embedded) = embedded_path(entity) {
|
||||
return Some((embedded, glam::DMat4::IDENTITY.to_cols_array()));
|
||||
}
|
||||
}
|
||||
let (profile, closed) = extrusion_profile_of(entity)?;
|
||||
let mut polyline = LwPolyline::new();
|
||||
for piece in &profile.pieces {
|
||||
let Curve::Line(line) = piece else {
|
||||
return None;
|
||||
};
|
||||
polyline
|
||||
.vertices
|
||||
.push(LwVertex::new(Vector2::new(line.start[0], line.start[1])));
|
||||
}
|
||||
if !closed {
|
||||
let Curve::Line(last) = profile.pieces.last()? else {
|
||||
return None;
|
||||
};
|
||||
polyline
|
||||
.vertices
|
||||
.push(LwVertex::new(Vector2::new(last.end[0], last.end[1])));
|
||||
}
|
||||
polyline.is_closed = closed;
|
||||
let normal = profile.plane.normal()?;
|
||||
let transform = glam::DMat4::from_cols(
|
||||
glam::DVec4::new(
|
||||
profile.plane.x_axis[0],
|
||||
profile.plane.x_axis[1],
|
||||
profile.plane.x_axis[2],
|
||||
0.0,
|
||||
),
|
||||
glam::DVec4::new(
|
||||
profile.plane.y_axis[0],
|
||||
profile.plane.y_axis[1],
|
||||
profile.plane.y_axis[2],
|
||||
0.0,
|
||||
),
|
||||
glam::DVec4::new(normal[0], normal[1], normal[2], 0.0),
|
||||
glam::DVec4::new(
|
||||
profile.plane.origin[0],
|
||||
profile.plane.origin[1],
|
||||
profile.plane.origin[2],
|
||||
1.0,
|
||||
),
|
||||
)
|
||||
.to_cols_array();
|
||||
Some((EmbeddedEntity::LwPolyline(polyline), transform))
|
||||
}
|
||||
|
||||
pub fn extrusion_history(
|
||||
profile: &EntityType,
|
||||
path: Option<&EntityType>,
|
||||
direction: [f64; 3],
|
||||
taper_angle: f64,
|
||||
reference_point: [f64; 3],
|
||||
) -> Option<SolidHistoryOperation> {
|
||||
let signed_height = Vec3::from(direction).length();
|
||||
let mut base = SolidHistoryNodeBase::new(1);
|
||||
base.transform = glam::DMat4::IDENTITY.to_cols_array();
|
||||
let (sweep_entity, sweep_entity_transform) = embedded_planar_entity(profile)?;
|
||||
let (path_entity, path_entity_transform) = match path {
|
||||
Some(path) => {
|
||||
let (entity, transform) = embedded_planar_entity(path)?;
|
||||
(Some(entity), transform)
|
||||
}
|
||||
None => (None, glam::DMat4::IDENTITY.to_cols_array()),
|
||||
};
|
||||
Some(SolidHistoryOperation::Extrusion(SolidHistorySweep {
|
||||
base,
|
||||
operation_major: 1,
|
||||
direction: Vector3::new(direction[0], direction[1], direction[2]),
|
||||
sweep_entity: Some(sweep_entity),
|
||||
path_entity,
|
||||
draft_angle: taper_angle,
|
||||
start_draft_distance: 0.0,
|
||||
end_draft_distance: signed_height,
|
||||
scale_factor: 1.0,
|
||||
sweep_entity_transform,
|
||||
path_entity_transform,
|
||||
reference_point: Vector3::new(
|
||||
reference_point[0],
|
||||
reference_point[1],
|
||||
reference_point[2],
|
||||
),
|
||||
..SolidHistorySweep::default()
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn sweep_history(
|
||||
profile: &EntityType,
|
||||
path: &EntityType,
|
||||
taper_angle: f64,
|
||||
reference_point: [f64; 3],
|
||||
) -> Option<SolidHistoryOperation> {
|
||||
let mut base = SolidHistoryNodeBase::new(1);
|
||||
base.transform = glam::DMat4::IDENTITY.to_cols_array();
|
||||
let (sweep_entity, sweep_entity_transform) = embedded_planar_entity(profile)?;
|
||||
Some(SolidHistoryOperation::Sweep(SolidHistorySweep {
|
||||
base,
|
||||
operation_major: 1,
|
||||
sweep_entity: Some(sweep_entity),
|
||||
path_entity: Some(embedded_path(path)?),
|
||||
draft_angle: taper_angle,
|
||||
scale_factor: 1.0,
|
||||
sweep_entity_transform,
|
||||
path_entity_transform: glam::DMat4::IDENTITY.to_cols_array(),
|
||||
reference_point: Vector3::new(
|
||||
reference_point[0],
|
||||
reference_point[1],
|
||||
reference_point[2],
|
||||
),
|
||||
..SolidHistorySweep::default()
|
||||
}))
|
||||
}
|
||||
|
||||
/// Builds a history-backed wall solid along a supported planar curve.
|
||||
pub fn polysolid(
|
||||
entity: &EntityType,
|
||||
|
||||
@@ -731,6 +731,92 @@ type MeshPickItem<'a> = (
|
||||
[f64; 6],
|
||||
);
|
||||
|
||||
type MeshEdgePickItem<'a> = (
|
||||
Handle,
|
||||
&'a [[f32; 3]],
|
||||
&'a [[f32; 3]],
|
||||
Option<acadrust::types::Transform>,
|
||||
);
|
||||
|
||||
/// Return the closest solid whose actual B-rep feature edge passes through the
|
||||
/// cursor aperture. Normal selection uses this path so a shaded body's broad
|
||||
/// triangle faces do not turn its entire interior into a pick target. Commands
|
||||
/// that deliberately acquire a face continue to use [`mesh_click_hit`].
|
||||
pub(crate) fn mesh_edge_click_hit<'a>(
|
||||
cursor: Point,
|
||||
meshes: impl Iterator<Item = MeshEdgePickItem<'a>>,
|
||||
view_rot: Mat4,
|
||||
eye: glam::DVec3,
|
||||
bounds: Rectangle,
|
||||
tolerance_px: f32,
|
||||
) -> Option<Handle> {
|
||||
if cursor.x < 0.0
|
||||
|| cursor.x > bounds.width
|
||||
|| cursor.y < 0.0
|
||||
|| cursor.y > bounds.height
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let tolerance = tolerance_px.max(1.0);
|
||||
let mut best: Option<(f32, f32, Handle)> = None;
|
||||
for (handle, edges, edges_low, transform) in meshes {
|
||||
if edges.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let model = transform.map(codec_transform_matrix);
|
||||
if model.is_some_and(|matrix| {
|
||||
!matrix.is_finite() || matrix.determinant().abs() <= 1e-18
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
for pair_start in (0..edges.len() - 1).step_by(2) {
|
||||
let world_point = |index: usize| {
|
||||
let hi = edges[index];
|
||||
let low = edges_low.get(index).copied().unwrap_or([0.0; 3]);
|
||||
let local = glam::DVec3::new(
|
||||
hi[0] as f64 + low[0] as f64,
|
||||
hi[1] as f64 + low[1] as f64,
|
||||
hi[2] as f64 + low[2] as f64,
|
||||
);
|
||||
model.map_or(local, |matrix| matrix.transform_point3(local))
|
||||
};
|
||||
let first = world_point(pair_start);
|
||||
let second = world_point(pair_start + 1);
|
||||
if !first.is_finite() || !second.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let first_ndc = view_rot.project_point3((first - eye).as_vec3());
|
||||
let second_ndc = view_rot.project_point3((second - eye).as_vec3());
|
||||
if !first_ndc.is_finite() || !second_ndc.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let to_screen = |point: glam::Vec3| {
|
||||
Point::new(
|
||||
(point.x + 1.0) * 0.5 * bounds.width,
|
||||
(1.0 - point.y) * 0.5 * bounds.height,
|
||||
)
|
||||
};
|
||||
let distance = dist_point_to_segment(
|
||||
cursor,
|
||||
to_screen(first_ndc),
|
||||
to_screen(second_ndc),
|
||||
);
|
||||
if distance > tolerance {
|
||||
continue;
|
||||
}
|
||||
let depth = (first_ndc.z + second_ndc.z) * 0.5;
|
||||
let replace = best.is_none_or(|(best_distance, best_depth, _)| {
|
||||
distance + 1e-4 < best_distance
|
||||
|| ((distance - best_distance).abs() <= 1e-4 && depth < best_depth)
|
||||
});
|
||||
if replace {
|
||||
best = Some((distance, depth, handle));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, _, handle)| handle)
|
||||
}
|
||||
|
||||
pub(crate) fn mesh_click_hit<'a>(
|
||||
cursor: Point,
|
||||
meshes: impl Iterator<Item = MeshPickItem<'a>>,
|
||||
|
||||
@@ -56,3 +56,24 @@ pub struct SelectionState {
|
||||
pub middle_last_pos: Option<Point>,
|
||||
pub middle_last_press_time: Option<Instant>,
|
||||
}
|
||||
|
||||
impl SelectionState {
|
||||
/// End every left-button selection gesture without disturbing the previous
|
||||
/// completed-window record or command-owned preview marquee. Grip editing
|
||||
/// owns the left button while engaged and calls this before/after placement
|
||||
/// so a small pointer move cannot also arm a box or lasso selection.
|
||||
pub fn clear_left_selection_gesture(&mut self) {
|
||||
self.left_down = false;
|
||||
self.left_press_pos = None;
|
||||
self.left_press_time = None;
|
||||
self.left_dragging = false;
|
||||
self.box_anchor = None;
|
||||
self.box_anchor_world = None;
|
||||
self.box_current = None;
|
||||
self.box_crossing = false;
|
||||
self.box_crossing_locked = false;
|
||||
self.poly_active = false;
|
||||
self.poly_points.clear();
|
||||
self.poly_crossing = false;
|
||||
}
|
||||
}
|
||||
@@ -3266,9 +3266,19 @@ impl Pipeline {
|
||||
&mut self,
|
||||
selected: &rustc_hash::FxHashSet<acadrust::Handle>,
|
||||
hovered: &rustc_hash::FxHashSet<acadrust::Handle>,
|
||||
edge_wires: &[WireModel],
|
||||
) {
|
||||
let edge_handles: rustc_hash::FxHashSet<acadrust::Handle> = edge_wires
|
||||
.iter()
|
||||
.filter_map(|wire| wire.name.strip_prefix("mesh-edge:"))
|
||||
.filter_map(|value| value.parse::<u64>().ok())
|
||||
.map(acadrust::Handle::new)
|
||||
.collect();
|
||||
let mut out = Vec::new();
|
||||
for handle in selected {
|
||||
for handle in selected
|
||||
.iter()
|
||||
.filter(|handle| !edge_handles.contains(handle))
|
||||
{
|
||||
if let Some(ranges) = self.mesh_ranges_by_handle.get(handle) {
|
||||
out.extend(ranges.iter().copied().map(|range| MeshHighlightDraw {
|
||||
range,
|
||||
@@ -3276,7 +3286,9 @@ impl Pipeline {
|
||||
}));
|
||||
}
|
||||
}
|
||||
for handle in hovered.iter().filter(|handle| !selected.contains(handle)) {
|
||||
for handle in hovered.iter().filter(|handle| {
|
||||
!selected.contains(handle) && !edge_handles.contains(handle)
|
||||
}) {
|
||||
if let Some(ranges) = self.mesh_ranges_by_handle.get(&handle) {
|
||||
out.extend(ranges.iter().copied().map(|range| MeshHighlightDraw {
|
||||
range,
|
||||
|
||||
@@ -1044,7 +1044,11 @@ impl shader::Primitive for Primitive {
|
||||
vp.selection_generation,
|
||||
);
|
||||
if hl_key != inner.cached_highlight_key {
|
||||
inner.update_mesh_highlight(&vp.selected_handles, &vp.hover_handles);
|
||||
inner.update_mesh_highlight(
|
||||
&vp.selected_handles,
|
||||
&vp.hover_handles,
|
||||
&vp.annotation_context_wires,
|
||||
);
|
||||
inner.cached_highlight_key = hl_key;
|
||||
}
|
||||
// Live overlay (command preview / interim / grip drag) — small and
|
||||
@@ -3324,6 +3328,7 @@ impl Scene {
|
||||
highlighted.sort_unstable_by_key(|(handle, _)| handle.value());
|
||||
|
||||
let empty_selection = rustc_hash::FxHashSet::default();
|
||||
let interaction_meshes = self.interaction_meshes_arc();
|
||||
let mut wires = Vec::new();
|
||||
for (handle, selected) in highlighted {
|
||||
let Some(entity) = self.document.get_entity(handle) else {
|
||||
@@ -3339,6 +3344,46 @@ impl Scene {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tint = if selected {
|
||||
WireModel::SELECTED
|
||||
} else {
|
||||
WireModel::HOVER
|
||||
};
|
||||
for set in interaction_meshes
|
||||
.iter()
|
||||
.filter(|set| set.entity_handle() == Some(handle))
|
||||
{
|
||||
let (edges, edges_low) = set.geometry_edges();
|
||||
if edges.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let mut points = Vec::with_capacity(edges.len() / 2 * 3);
|
||||
for pair_start in (0..edges.len() - 1).step_by(2) {
|
||||
for index in [pair_start, pair_start + 1] {
|
||||
let high = edges[index];
|
||||
let low = edges_low.get(index).copied().unwrap_or([0.0; 3]);
|
||||
let local = acadrust::types::Vector3::new(
|
||||
high[0] as f64 + low[0] as f64,
|
||||
high[1] as f64 + low[1] as f64,
|
||||
high[2] as f64 + low[2] as f64,
|
||||
);
|
||||
let world = set
|
||||
.instance_transform
|
||||
.map_or(local, |transform| transform.apply(local));
|
||||
points.push([world.x, world.y, world.z]);
|
||||
}
|
||||
points.push([f64::NAN; 3]);
|
||||
}
|
||||
let mut edge_wire = WireModel::solid_f64(
|
||||
format!("mesh-edge:{}", handle.value()),
|
||||
points,
|
||||
tint,
|
||||
selected,
|
||||
);
|
||||
edge_wire.line_weight_px = 2.0;
|
||||
wires.push(edge_wire);
|
||||
}
|
||||
|
||||
if matches!(entity, EntityType::Hatch(_)) {
|
||||
if selected {
|
||||
if let Some(mut wire) = self.hatch_outline_wire(handle) {
|
||||
@@ -3390,12 +3435,6 @@ impl Scene {
|
||||
.map(|context| context.scale)
|
||||
})
|
||||
.flatten();
|
||||
let tint = if selected {
|
||||
WireModel::SELECTED
|
||||
} else {
|
||||
WireModel::HOVER
|
||||
};
|
||||
|
||||
for scale in scales {
|
||||
if displayed_scale == Some(scale) {
|
||||
continue;
|
||||
|
||||
Reference in new issue
Block a user