Merge main and preserve revolve history grips

This commit is contained in:
Hakan Seven committed 2026-09-02 18:05:50 +03:00
commit b1deec753c
38 files changed
+2989 -453

No files matched your search

+318 -28
View File
@@ -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;
+7
View File
@@ -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
View File
@@ -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 {
+9 -5
View File
@@ -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
View File
@@ -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>,
+27
View File
@@ -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
View File
@@ -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();
}
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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);