Merge main into Select-Color-Redesign

Keep both sides: main's ViewBackground colour target and PREVIEW icon
next to this branch's Interface target and LIST/SEARCH icons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8
This commit is contained in:
Claude committed 2026-10-14 19:10:33 +00:00
commit 451b0f5d78
231 files changed
+32217 -3423

No files matched your search

+221 -7
View File
@@ -887,11 +887,9 @@ fn locked_palette_remove_refuses_a_block_with_an_open_bedit_session() {
);
}
/// Nested BEDIT sessions all count: the outer and the inner record are both
/// open, so both are refused — the whole session stack is handed to the
/// service, not just the active tab.
/// Switching replaces the open session; the active definition stays protected.
#[test]
fn locked_palette_remove_refuses_every_block_of_a_nested_bedit_session() {
fn locked_palette_remove_refuses_the_open_block_after_switching() {
let mut app = app();
let inner = "BO_NEST_INNER";
define_block(&mut app, inner);
@@ -915,10 +913,10 @@ fn locked_palette_remove_refuses_every_block_of_a_nested_bedit_session() {
begin_bedit(&mut app, inner_reference);
assert_eq!(
app.tabs[tab(&app)].block_edits.len(),
2,
"the nested session did not open"
1,
"switching must keep one session"
);
for name in [outer, inner] {
for name in [inner] {
palette_remove(&mut app, name);
assert!(record_exists(&app, name), "{name} went while open");
let expected = command_line_error(&format!(
@@ -1347,3 +1345,219 @@ fn locked_purge_skips_a_block_with_an_open_bedit_session() {
assert!(record_exists(&app, &name));
let _ = app.dispatch_command("BEDIT_DISCARD");
}
#[test]
fn locked_attedit_attribute_flags_cases() {
use crate::ui::window::attribute_editor::AttrRow;
use codec::entities::{AttributeDefinition, AttributeEntity};
use codec::tables::BlockRecord;
// Verify flag behavior in ATTEDIT conversion and apply:
// 1. Normal attribute: editable, modifies value
let mut normal_entity =
AttributeEntity::new("NORM_TAG".to_string(), "original_norm".to_string());
let normal_row = AttrRow {
tag: "NORM_TAG".to_string(),
prompt: "Enter norm:".to_string(),
value: "edited_norm".to_string(),
text_style: "STANDARD".to_string(),
height: "2.5".to_string(),
rotation: "0".to_string(),
width_factor: "1".to_string(),
oblique: "0".to_string(),
h_align: codec::entities::HorizontalAlignment::Left,
v_align: codec::entities::VerticalAlignment::Baseline,
backwards: false,
upside_down: false,
layer: "0".to_string(),
color: codec::types::Color::ByLayer,
linetype: "ByLayer".to_string(),
line_weight: codec::types::LineWeight::ByLayer,
is_constant: false,
};
assert!(!normal_row.is_constant);
// 2. Constant attribute: is_constant is true, apply ignores value edit
let mut const_entity =
AttributeEntity::new("CONST_TAG".to_string(), "original_const".to_string());
const_entity.flags.constant = true;
let mut const_row = normal_row.clone();
const_row.tag = "CONST_TAG".to_string();
const_row.value = "attempted_edit".to_string();
const_row.is_constant = true;
// 3. Invisible attribute: editable in ATTEDIT (flags.invisible is true, but is_constant is false)
let mut invis_entity =
AttributeEntity::new("INVIS_TAG".to_string(), "original_invis".to_string());
invis_entity.flags.invisible = true;
let mut invis_row = normal_row.clone();
invis_row.tag = "INVIS_TAG".to_string();
invis_row.value = "edited_invis".to_string();
invis_row.is_constant = false;
// 4. Preset attribute: editable in ATTEDIT (preset only affects initial insertion prompt)
let mut preset_entity =
AttributeEntity::new("PRESET_TAG".to_string(), "original_preset".to_string());
preset_entity.flags.preset = true;
let mut preset_row = normal_row.clone();
preset_row.tag = "PRESET_TAG".to_string();
preset_row.value = "edited_preset".to_string();
preset_row.is_constant = false;
// Verify metadata mapping from CadDocument
let mut doc = codec::CadDocument::new();
let next = doc.next_handle();
let br_h = Handle::new(next);
let mut br = BlockRecord::new("FLAG_BLOCK");
br.handle = br_h;
br.block_entity_handle = Handle::new(next + 1);
br.block_end_handle = Handle::new(next + 2);
let mut ad_norm = AttributeDefinition::new(
"NORM_TAG".to_string(),
"Enter norm:".to_string(),
"def".to_string(),
);
let mut ad_const = AttributeDefinition::new(
"CONST_TAG".to_string(),
"Enter const:".to_string(),
"def".to_string(),
);
ad_const.flags.constant = true;
let mut ad_invis = AttributeDefinition::new(
"INVIS_TAG".to_string(),
"Enter invis:".to_string(),
"def".to_string(),
);
ad_invis.flags.invisible = true;
let mut ad_preset = AttributeDefinition::new(
"PRESET_TAG".to_string(),
"Enter preset:".to_string(),
"def".to_string(),
);
ad_preset.flags.preset = true;
let h1 = Handle::new(next + 3);
let h2 = Handle::new(next + 4);
let h3 = Handle::new(next + 5);
let h4 = Handle::new(next + 6);
ad_norm.common.handle = h1;
ad_const.common.handle = h2;
ad_invis.common.handle = h3;
ad_preset.common.handle = h4;
br.entity_handles = vec![h1, h2, h3, h4];
doc.block_records.add(br).unwrap();
doc.add_entity(EntityType::AttributeDefinition(ad_norm))
.unwrap();
doc.add_entity(EntityType::AttributeDefinition(ad_const))
.unwrap();
doc.add_entity(EntityType::AttributeDefinition(ad_invis))
.unwrap();
doc.add_entity(EntityType::AttributeDefinition(ad_preset))
.unwrap();
let meta = crate::app::update::command::block_attr_meta(&doc, "FLAG_BLOCK");
assert!(!meta.get("NORM_TAG").unwrap().constant);
assert!(meta.get("CONST_TAG").unwrap().constant);
assert!(!meta.get("INVIS_TAG").unwrap().constant);
assert!(!meta.get("PRESET_TAG").unwrap().constant);
// Test conversion from AttributeEntity to AttrRow
let r_norm = crate::app::update::command::attr_row_from_entity(&normal_entity, &meta);
assert!(!r_norm.is_constant);
assert_eq!(r_norm.prompt, "Enter norm:");
let r_const = crate::app::update::command::attr_row_from_entity(&const_entity, &meta);
assert!(r_const.is_constant);
assert_eq!(r_const.prompt, "Enter const:");
let r_invis = crate::app::update::command::attr_row_from_entity(&invis_entity, &meta);
assert!(!r_invis.is_constant);
assert_eq!(r_invis.prompt, "Enter invis:");
let r_preset = crate::app::update::command::attr_row_from_entity(&preset_entity, &meta);
assert!(!r_preset.is_constant);
assert_eq!(r_preset.prompt, "Enter preset:");
// Test apply_attr_row behavior on constant vs non-constant
// Normal: applies
let mut edited_norm_row = r_norm.clone();
edited_norm_row.value = "edited_norm".to_string();
assert!(crate::app::update::command::apply_attr_row(
&mut normal_entity,
&edited_norm_row
));
assert_eq!(normal_entity.get_value(), "edited_norm");
// Constant: ignores value change!
let mut edited_const_row = r_const.clone();
edited_const_row.value = "attempted_edit".to_string();
assert!(!crate::app::update::command::apply_attr_row(
&mut const_entity,
&edited_const_row
));
assert_eq!(const_entity.get_value(), "original_const");
// Invisible: applies
let mut edited_invis_row = r_invis.clone();
edited_invis_row.value = "edited_invis".to_string();
assert!(crate::app::update::command::apply_attr_row(
&mut invis_entity,
&edited_invis_row
));
assert_eq!(invis_entity.get_value(), "edited_invis");
// Preset: applies
let mut edited_preset_row = r_preset.clone();
edited_preset_row.value = "edited_preset".to_string();
assert!(crate::app::update::command::apply_attr_row(
&mut preset_entity,
&edited_preset_row
));
assert_eq!(preset_entity.get_value(), "edited_preset");
}
#[test]
fn locked_bedit_insert_refuses_cycle() {
let mut app = app();
let name_a = "BEDIT_CYC_A";
let name_b = "BEDIT_CYC_B";
define_block(&mut app, name_a);
// B contains an insert of A
let ins_a = Insert::new(name_a.to_string(), Vector3::ZERO);
let i = tab(&app);
app.tabs[i]
.scene
.define_block_raw(name_b, Vector3::ZERO, vec![EntityType::Insert(ins_a)]);
// Now open BEDIT for block A
let insert_a = add_insert(&mut app, name_a);
begin_bedit(&mut app, insert_a);
assert!(bedit_open(&app));
// Try committing an INSERT of B into A inside BEDIT space:
// Inserting B into A would create A -> B -> A cycle
let _ = app.dispatch_command("-INSERT");
let mut feed = |text: &str| {
let _ = app.update(crate::app::Message::CommandInput(text.to_string()));
let _ = app.update(crate::app::Message::CommandSubmit);
};
feed(name_b);
feed("0,0");
feed("1");
feed("1");
feed("0");
// Verify refusal error on command line
let errs = errors(&app);
assert!(
errs.iter()
.any(|e| e.contains("circular dependency detected")),
"expected cycle error in errors: {errs:?}"
);
let _ = app.dispatch_command("BEDIT_DISCARD");
}
+5 -1
View File
@@ -427,7 +427,11 @@ impl OpenCADStudio {
for def in deps.blocks {
let scene = &mut self.tabs[i].scene;
if redefine && def.name.eq_ignore_ascii_case(name) {
scene.redefine_block_raw(&def.name, def.base_point, def.entities);
if let Err(err) = scene.redefine_block_raw(&def.name, def.base_point, def.entities)
{
self.command_line.push_error(&err);
return Err(err);
}
} else if scene.document.block_records.get(&def.name).is_none() {
scene.define_block_raw(&def.name, def.base_point, def.entities);
}
+233 -38
View File
@@ -55,6 +55,16 @@ fn polyline_add_vertex_target(entity: &AcadEntityType, grip_id: usize) -> Option
AcadEntityType::Polyline3D(polyline) if grip_id < polyline.vertices.len() => {
Some((grip_id + 1, polyline.vertices.len()))
}
// A hatch boundary vertex: the new vertex is the next grip.
AcadEntityType::Hatch(hatch) => {
use crate::entities::traits::Grippable;
use crate::scene::model::object::GripMenuAction;
hatch
.grip_menu(grip_id)
.iter()
.any(|item| matches!(item.action, GripMenuAction::AddVertex))
.then(|| (grip_id + 1, hatch.grips().len()))
}
_ => None,
}
}
@@ -65,6 +75,9 @@ fn polyline_vertex_count(entity: &AcadEntityType) -> Option<usize> {
AcadEntityType::Polyline(polyline) => Some(polyline.vertices.len()),
AcadEntityType::Polyline2D(polyline) => Some(polyline.vertices.len()),
AcadEntityType::Polyline3D(polyline) => Some(polyline.vertices.len()),
AcadEntityType::Hatch(hatch) => {
Some(crate::entities::traits::Grippable::grips(hatch).len())
}
_ => None,
}
}
@@ -138,7 +151,7 @@ impl OpenCADStudio {
/// Close queued drawings until a dirty tab requires confirmation. Queue
/// entries use stable document ids because removing an earlier tab changes
/// every later vector index.
pub(super) fn continue_tab_close_queue(&mut self) -> Task<Message> {
pub(in crate::app) fn continue_tab_close_queue(&mut self) -> Task<Message> {
let mut tasks = Vec::new();
while let Some(tab_id) = self.pending_tab_closes.pop_front() {
let Some(idx) = self.tabs.iter().position(|tab| tab.id == tab_id) else {
@@ -147,6 +160,13 @@ impl OpenCADStudio {
if self.tabs[idx].is_start {
continue;
}
if self.tabs[idx].active_block_edit.is_some() || self.tabs[idx].block_test.is_some() {
tasks.push(self.on_tab_close(idx));
if self.pending_block_close.is_some() || self.pending_close.is_some() {
break;
}
continue;
}
if self.tabs[idx].dirty {
self.pending_close = Some(crate::app::PendingClose::Tab(tab_id));
tasks.push(self.open_unsaved_dialog_window());
@@ -157,11 +177,42 @@ impl OpenCADStudio {
Task::batch(tasks)
}
pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
pub(in crate::app) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
let test_return_id = self
.tabs
.get(idx)
.and_then(|tab| tab.block_test.as_ref())
.map(|test| {
if idx == self.active_tab {
test.source_id
} else {
self.tabs[self.active_tab].id
}
});
if test_return_id.is_some() {
if idx == self.active_tab {
self.cancel_active_grip_edit();
self.grip_pending = None;
self.visibility_popup = None;
self.grip_popup = None;
self.grip_hover = None;
}
self.tabs[idx].dirty = false;
}
// Start tab is fixed — close requests on it are no-ops.
if self.tabs.get(idx).map_or(false, |t| t.is_start) {
return Task::none();
}
if self
.tabs
.get(idx)
.is_some_and(|tab| tab.active_block_edit.is_some())
{
return self.request_block_edit_close(
idx,
crate::app::block_editor::BlockEditContinuation::Tab(self.tabs[idx].id),
);
}
// Closing a tab shifts indices / the active tab. The attribute
// editor holds a document-local handle into one tab, so drop it
// now rather than risk it applying to a different tab's document.
@@ -219,6 +270,16 @@ impl OpenCADStudio {
// The active tab is now either a brand-new blank or a
// different existing tab; in both cases the ribbon needs
// to track that doc's defaults / selection. #21.
if let Some(index) =
test_return_id.and_then(|id| self.tabs.iter().position(|tab| tab.id == id))
{
self.active_tab = index;
self.adopt_header_sysvars(index);
}
self.ribbon
.set_block_testing(self.tabs[self.active_tab].block_test.is_some());
self.ribbon
.set_block_editing(self.tabs[self.active_tab].active_block_edit.is_some());
self.sync_ribbon_layers();
self.sync_ribbon_styles();
self.sync_ribbon_from_selection();
@@ -868,6 +929,13 @@ impl OpenCADStudio {
if let Some(task) = self.try_selection_keyword(&text) {
return task;
}
// Under a typed `<angle` a bare number is the distance along it,
// before the step reads it as a value of its own.
if self.angle_override.is_some() {
if let Some(task) = self.try_direct_distance_entry(&text) {
return task;
}
}
if self.tabs[i]
.active_cmd
.as_ref()
@@ -895,6 +963,10 @@ impl OpenCADStudio {
return self.feed_command(crate::command::StepInput::Enter);
}
if self.try_angle_override(&text) {
return Task::none();
}
// OTRACK and Extension both allow a bare scalar to act as a
// distance measured along the active reference ray.
if self.active_distance_ray(i).is_some() {
@@ -1001,6 +1073,25 @@ impl OpenCADStudio {
/// has an established anchor, and `text` parses as a scalar distance,
/// project along the active reference ray (if any) or the current cursor
/// direction in the active UCS plane.
/// `<angle` at a point prompt locks the next point's direction from the
/// last point; a bare distance then runs along it. True when consumed.
pub(in crate::app) fn try_angle_override(&mut self, text: &str) -> bool {
let (Some(angle), Some(_)) = (text.trim().strip_prefix('<'), self.last_point) else {
return false;
};
let Some(angle) = crate::entities::common::parse_typed_direction(angle) else {
return false;
};
let local = glam::DVec3::new(angle.cos(), angle.sin(), 0.0);
self.angle_override = Some(match &self.tabs[self.active_tab].active_ucs {
Some(ucs) => ucs_rotate_vec(local, ucs),
None => local,
});
let shown = crate::entities::common::format_direction(angle);
self.command_line.push_output(crate::tf!("Angle Override: {shown}").as_ref());
true
}
pub(in crate::app) fn try_direct_distance_entry(
&mut self,
text: &str,
@@ -2102,18 +2193,64 @@ impl OpenCADStudio {
}
}
/// Attach a clipboard image read on the web (a pasted screenshot) the way
/// the IMAGE command's pick does: register the bytes name-keyed, decode
/// for the file's true pixel size, then commit the entity — but placed
/// directly at the cursor (0,0 before any pointer move) with the command's
/// Read an image off the system clipboard for a paste that found no
/// text. The web gets the encoded file from the Clipboard API; native
/// gets pixels, which are written as a PNG beside the drawing (or, for a
/// drawing never saved, in the config folder) so the image entity keeps
/// a file to point at.
pub(in crate::app) fn read_system_clipboard_image(&self) -> Task<Message> {
#[cfg(target_arch = "wasm32")]
{
Task::perform(
crate::sys::read_clipboard_image(),
Message::SystemClipboardPasteImage,
)
}
#[cfg(not(target_arch = "wasm32"))]
{
let folder = self.tabs[self.active_tab]
.current_path
.as_ref()
.and_then(|path| path.parent().map(std::path::Path::to_path_buf))
.filter(|folder| !folder.as_os_str().is_empty())
.or_else(|| crate::config::config_dir().map(|dir| dir.join("pasted")));
iced::clipboard::read_image().map(move |image| {
let saved = image.ok().zip(folder.clone()).and_then(|(image, folder)| {
let pixels = image::RgbaImage::from_raw(
image.size.width,
image.size.height,
image.rgba.to_vec(),
)?;
let mut png = std::io::Cursor::new(Vec::new());
pixels.write_to(&mut png, image::ImageFormat::Png).ok()?;
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_millis());
let name = crate::sys::unique_pasted_image_name(
&format!("pasted-{stamp}"),
"png",
|name| folder.join(name).exists(),
);
let path = folder.join(name);
std::fs::create_dir_all(&folder).ok()?;
std::fs::write(&path, png.get_ref()).ok()?;
Some((path.to_string_lossy().into_owned(), png.into_inner()))
});
Message::SystemClipboardPasteImage(saved)
})
}
}
/// Attach a clipboard image (a pasted screenshot) the way the IMAGE
/// command's pick does: register the bytes name-keyed, decode for the
/// file's true pixel size, then commit the entity — but placed directly
/// at the cursor (0,0 before any pointer move) with the command's
/// default width, skipping the pick prompts a paste should not run.
#[cfg(target_arch = "wasm32")]
pub(super) fn paste_clipboard_image(&mut self, name: String, bytes: Vec<u8>) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].is_start {
self.command_line.push_error(
crate::tr!("common", "no-drawing-open-use-new-or-open-first").as_ref(),
);
self.command_line
.push_error(crate::tr!("common", "no-drawing-open-use-new-or-open-first").as_ref());
return Task::none();
}
crate::scene::model::image_model::register_source(&name, std::sync::Arc::new(bytes));
@@ -2227,6 +2364,10 @@ impl OpenCADStudio {
let append = matches!(scope, crate::app::QSelectScope::CurrentSpace)
&& remembered.as_ref().is_some_and(|settings| settings.append);
self.qselect = Some(crate::app::QSelectState {
conditions: remembered
.as_ref()
.map_or_else(Vec::new, |settings| settings.conditions.clone()),
editing_condition: None,
scope,
available_types,
available_properties,
@@ -3685,6 +3826,40 @@ impl OpenCADStudio {
Task::none()
}
/// An associative array row: the typed value changes the array's
/// parameter and its items are placed again, in one undo step.
fn on_assoc_array_field_commit(&mut self, i: usize, field: &'static str, handles: &[Handle]) {
let Some(text) = self.tabs[i]
.properties
.edit_buf
.remove(&crate::ui::properties::FieldKey::Geom(field))
else {
return;
};
let arrays: Vec<_> = handles
.iter()
.filter_map(|handle| {
crate::scene::assoc_array::array_of_insert(&self.tabs[i].scene.document, *handle)
})
.collect();
if arrays.is_empty() {
return;
}
self.push_undo_snapshot(i, "ARRAYEDIT");
for array in &arrays {
if let Err(error) = crate::scene::assoc_array::apply_field(
&mut self.tabs[i].scene,
array,
field,
&crate::app::expr_eval::eval_to_string(&text),
) {
self.command_line.push_error(&format!("ARRAYEDIT: {error}"));
}
}
self.tabs[i].dirty = true;
self.refresh_properties();
}
pub(super) fn on_prop_geom_commit(&mut self, field: &'static str) -> Task<Message> {
use crate::ui::window::named_parameters::ParamField;
// A dynamic dimension's Name/Expression rows edit its parameter.
@@ -3702,6 +3877,10 @@ impl OpenCADStudio {
let i = self.active_tab;
self.tabs[i].properties.active_field = None;
let handles = self.property_target_handles(i);
if field.starts_with("assoc_array_") {
self.on_assoc_array_field_commit(i, field, &handles);
return Task::none();
}
if !handles.is_empty() {
let driven_reference = match field {
"start_x" | "start_y" | "start_z" => Some(Some(0)),
@@ -3763,7 +3942,9 @@ impl OpenCADStudio {
return Task::none();
}
}
if field.starts_with("ul_") {
if field.starts_with("ul_")
|| matches!(field, "ri_bright" | "ri_contrast" | "ri_fade")
{
if let Err(message) = crate::entities::underlay::validate_property(field, &val)
{
self.command_line.push_error(message);
@@ -4262,11 +4443,11 @@ impl OpenCADStudio {
Some(codec::EntityType::Insert(ins)) if !ins.attributes.is_empty() => {
// The prompt text lives on the block's ATTDEFs, not on the
// attribute instances — map tag → prompt from the definition.
let prompts = block_attr_prompts(doc, &ins.block_name);
let meta = block_attr_meta(doc, &ins.block_name);
let rows = ins
.attributes
.iter()
.map(|a| attr_row_from_entity(a, &prompts))
.map(|a| attr_row_from_entity(a, &meta))
.collect::<Vec<_>>();
Ok((ins.block_name.clone(), rows))
}
@@ -4298,7 +4479,7 @@ impl OpenCADStudio {
/// Close the attribute editor without applying, if it is open. Used when
/// the tab it belongs to closes or the active tab switches — its handle is
/// document-local and must not outlive its tab.
pub(super) fn cancel_attr_editor(&mut self) {
pub(in crate::app) fn cancel_attr_editor(&mut self) {
if self.active_modal == Some(crate::app::ModalKind::AttributeEditor) {
self.active_modal = None;
self.reset_modal_geometry();
@@ -4360,16 +4541,47 @@ impl OpenCADStudio {
}
}
pub(crate) struct BlockAttrMeta {
pub(crate) prompt: String,
pub(crate) constant: bool,
}
/// Map each attribute tag to the metadata (prompt text, constant flag) declared on
/// the block's matching ATTDEF. Tags with no definition are absent.
pub(crate) fn block_attr_meta(
doc: &codec::CadDocument,
block_name: &str,
) -> rustc_hash::FxHashMap<String, BlockAttrMeta> {
let mut map = rustc_hash::FxHashMap::default();
if let Some(br) = doc.block_records.get(block_name) {
for &eh in &br.entity_handles {
if let Some(codec::EntityType::AttributeDefinition(ad)) = doc.get_entity(eh) {
map.insert(
ad.tag.clone(),
BlockAttrMeta {
prompt: ad.prompt.clone(),
constant: ad.flags.constant,
},
);
}
}
}
map
}
/// Build the editor's working copy of one attribute. Angles are shown in
/// degrees; numeric fields become strings so the user can type freely.
fn attr_row_from_entity(
pub(crate) fn attr_row_from_entity(
a: &codec::entities::AttributeEntity,
prompts: &rustc_hash::FxHashMap<String, String>,
meta: &rustc_hash::FxHashMap<String, BlockAttrMeta>,
) -> AttrRow {
let fmt = |v: f64| format!("{v}");
let ad_meta = meta.get(&a.tag);
let prompt = ad_meta.map(|m| m.prompt.clone()).unwrap_or_default();
let is_constant = a.flags.constant || ad_meta.map_or(false, |m| m.constant);
AttrRow {
tag: a.tag.clone(),
prompt: prompts.get(&a.tag).cloned().unwrap_or_default(),
prompt,
value: a.get_value().to_string(),
text_style: a.text_style.clone(),
height: fmt(a.height),
@@ -4384,15 +4596,16 @@ fn attr_row_from_entity(
color: a.common.color,
linetype: a.common.linetype.clone(),
line_weight: a.common.line_weight,
is_constant,
}
}
/// Write one edited row back onto its attribute; returns whether anything
/// changed. Numeric fields are parsed (angles from degrees); an unparseable
/// field is left as-is.
fn apply_attr_row(a: &mut codec::entities::AttributeEntity, row: &AttrRow) -> bool {
/// field is left as-is. Constant attributes ignore edits to their value.
pub(crate) fn apply_attr_row(a: &mut codec::entities::AttributeEntity, row: &AttrRow) -> bool {
let mut ch = false;
if a.get_value() != row.value {
if !row.is_constant && a.get_value() != row.value {
a.set_value(row.value.clone());
ch = true;
}
@@ -4463,24 +4676,6 @@ fn apply_attr_row(a: &mut codec::entities::AttributeEntity, row: &AttrRow) -> bo
ch
}
/// Map each attribute tag to the prompt text declared on the block's matching
/// ATTDEF. Attribute instances (ATTRIB) carry only tag + value; the prompt is
/// defined once on the block definition. Tags with no definition are absent.
fn block_attr_prompts(
doc: &codec::CadDocument,
block_name: &str,
) -> rustc_hash::FxHashMap<String, String> {
let mut map = rustc_hash::FxHashMap::default();
if let Some(br) = doc.block_records.get(block_name) {
for &eh in &br.entity_handles {
if let Some(codec::EntityType::AttributeDefinition(ad)) = doc.get_entity(eh) {
map.insert(ad.tag.clone(), ad.prompt.clone());
}
}
}
map
}
#[cfg(test)]
mod layer_rename_tests {
use crate::app::OpenCADStudio;
+8 -2
View File
@@ -22,6 +22,9 @@ use iced::{mouse, Point, Task};
impl OpenCADStudio {
pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task<Message> {
if self.refuse_block_test_save(tab_idx) {
return Task::none();
}
// Every entry here is Save-As semantics (the SAVEAS command, the
// recovery save-as, the native failure redirects): the user is
// choosing a destination, so on the web the FS Access picker must
@@ -580,11 +583,14 @@ impl OpenCADStudio {
.define_block_raw(&def.name, def.base_point, def.entities);
}
if redefine {
self.tabs[i].scene.redefine_block_raw(
if let Err(err) = self.tabs[i].scene.redefine_block_raw(
&name,
codec::types::Vector3::new(base.x, base.y, base.z),
entities,
);
) {
self.command_line.push_error(&err);
return Err(err);
}
} else {
self.tabs[i]
.scene
+4
View File
@@ -29,6 +29,10 @@ impl OpenCADStudio {
&self,
i: usize,
) -> Option<(glam::DVec3, glam::DVec3)> {
// A typed `<angle` locks the way from the last point.
if let (Some(dir), Some(base)) = (self.angle_override, self.last_point) {
return Some((base, dir));
}
// OTRACK already exposes its acquired world-space ray.
if let Some((base, dir)) = self.otrack_active {
return Some((base, dir));
+91 -21
View File
@@ -780,6 +780,9 @@ impl OpenCADStudio {
zoom_factor: self.zoom_factor,
cursor_size: self.cursor_size,
pick_box: self.pick_box,
hatch_associative: self.hatch_associative,
show_menu_bar: self.show_menu_bar,
ribbon_closed: self.ribbon_closed,
options_tab: self.options_tab,
show_viewcube: self.show_viewcube,
show_ucs_icon: self.show_ucs_icon,
@@ -793,9 +796,13 @@ impl OpenCADStudio {
image_frame: crate::scene::frame::profile_image_mode(),
attdef_on_screen: crate::modules::draw::draw::attdef::session().on_screen,
field_display: crate::entities::field::display(),
xref_fade: crate::scene::cache::block_cache::xref_fade_ctl(),
file_dia: crate::sys::file_dia(),
attach_filter: crate::sys::attach_filter(),
surface_associativity: crate::scene::model::sweep_model::surface_associativity(),
ucs_ortho: crate::modules::view::view_cmd::ucs_ortho(),
ucs_view: crate::modules::view::view_cmd::ucs_view(),
layout_create_viewport: crate::scene::layout_create_viewport(),
ncopy_bind: self.ncopy_bind,
cursor_type: self.cursor_type,
crosshair_color: self.crosshair_color,
@@ -845,6 +852,7 @@ impl OpenCADStudio {
quick_properties: self.quick_properties,
language: self.language,
cliprompt_lines: crate::app::settings::clamp_clipromptlines(self.cliprompt_lines),
command_line_opacity: self.command_line.opacity_percent(),
commandline_fade_ms: crate::app::settings::clamp_commandline_fade_ms(
self.commandline_fade_ms,
),
@@ -866,6 +874,8 @@ impl OpenCADStudio {
block_mru_list: self.block_mru_list,
block_redefine_mode: self.block_redefine_mode,
block_navigate: self.block_navigate.clone(),
refedit: self.refedit_options.clone(),
xfadectl: crate::scene::cache::block_cache::refedit_fade_ctl() as u8,
count_color: self.count_palette.color,
count_error_color: self.count_palette.error_color,
count_service: self.count_palette.service,
@@ -891,6 +901,9 @@ impl OpenCADStudio {
self.zoom_factor_input = self.zoom_factor.to_string();
self.cursor_size = s.cursor_size.clamp(1, 100);
self.pick_box = s.pick_box.clamp(0, 50);
self.hatch_associative = s.hatch_associative;
self.show_menu_bar = s.show_menu_bar;
self.ribbon_closed = s.ribbon_closed;
self.options_tab = s.options_tab;
self.spacemouse_preferences = s.spacemouse;
// These four drive real features with commands and status-bar pills,
@@ -909,9 +922,14 @@ impl OpenCADStudio {
crate::scene::frame::set_profile_image_mode(s.image_frame);
crate::modules::draw::draw::attdef::session().on_screen = s.attdef_on_screen;
crate::entities::field::set_display(s.field_display);
crate::scene::cache::block_cache::set_xref_fade_ctl(s.xref_fade);
self.ribbon.xref_fade = crate::scene::cache::block_cache::xref_fade_ctl();
crate::sys::set_file_dia(s.file_dia);
crate::sys::set_attach_filter(s.attach_filter);
crate::scene::model::sweep_model::set_surface_associativity(s.surface_associativity);
crate::modules::view::view_cmd::set_ucs_ortho(s.ucs_ortho);
crate::modules::view::view_cmd::set_ucs_view(s.ucs_view);
crate::scene::set_layout_create_viewport(s.layout_create_viewport);
self.ncopy_bind = s.ncopy_bind;
self.cursor_type = s.cursor_type;
self.crosshair_color = s.crosshair_color;
@@ -996,6 +1014,7 @@ impl OpenCADStudio {
self.cliprompt_lines = crate::app::settings::clamp_clipromptlines(s.cliprompt_lines);
self.command_line
.set_cliprompt_lines(self.cliprompt_lines.clamp(0, 50) as u8);
self.command_line.set_opacity_percent(s.command_line_opacity);
self.commandline_fade_ms =
crate::app::settings::clamp_commandline_fade_ms(s.commandline_fade_ms);
self.command_line
@@ -1018,6 +1037,8 @@ impl OpenCADStudio {
self.block_mru_list = s.block_mru_list.min(100);
self.block_redefine_mode = s.block_redefine_mode.min(2);
self.block_navigate = s.block_navigate.clone();
crate::scene::cache::block_cache::set_refedit_fade_ctl(i32::from(s.xfadectl.min(90)));
self.refedit_options = s.refedit.clone();
self.count_palette.color = s.count_color;
self.count_palette.error_color = s.count_error_color;
self.count_palette.service = s.count_service;
@@ -1321,6 +1342,7 @@ impl OpenCADStudio {
.map(|p| p.to_string_lossy().into_owned())
.collect(),
limit: self.recent_limit,
view: self.recent_view,
},
start: crate::app::config::StartConfig {
section: self.start_section,
@@ -1392,6 +1414,7 @@ impl OpenCADStudio {
);
self.recent_files.truncate(self.recent_limit);
self.recent_limit_input = self.recent_limit.to_string();
self.recent_view = cfg.recent.view;
// Thumbnails are decoded by a background task queued at boot
// (`refresh_recent_thumbs`) — never here on the boot path.
self.start_section = cfg.start.section;
@@ -1741,6 +1764,11 @@ impl OpenCADStudio {
version: codec::DxfVersion,
set_current_path: bool,
) -> Result<(), crate::io::SaveFailure> {
if self.tabs[i].block_test.is_some() {
return Err(crate::io::SaveFailure::other(
"Test Block is temporary and cannot be saved",
));
}
let previous_autosave = self.autosave_target(i);
if self.tabs[i].recovery_save_as_required
&& self.tabs[i]
@@ -2120,22 +2148,7 @@ impl OpenCADStudio {
) {
crate::scene::text::ttf_glyph::clear_fallback_cache();
}
// Current model-space annotation scale comes from the drawing's
// CANNOSCALEVALUE (paper/drawing factor). Convert its inverse into
// drawing units as well: metric annotation sizes are paper millimetres
// and imperial annotation sizes are paper inches.
// Current model-space annotation scale comes from the drawing's
// CANNOSCALEVALUE (paper/drawing factor). Convert its paper unit into
// the drawing's INSUNITS as well, so e.g. a metre drawing uses
// 0.001 model units for 1 mm of paper at 1:1.
let cannoscale_value = self.tabs[i].scene.document.header.annotation_scale_value;
let unit_factor = self.tabs[i].scene.annotation_scale_unit_factor();
self.tabs[i].scene.annotation_scale = if cannoscale_value > 1e-9 {
((1.0 / cannoscale_value) / unit_factor) as f32
} else {
(1.0 / unit_factor) as f32
};
self.tabs[i].scene.sync_annotation_scale_from_header();
// Open-time breakdown so regressions are visible immediately.
// `total` is wall time from the Open click to here (post-xref,
@@ -2193,6 +2206,12 @@ impl OpenCADStudio {
self.tabs[i].scene.load_current_layout_state();
self.tabs[i].refresh_active_ucs();
}
// A layout saved with a model viewport entered reopens inside it,
// with that viewport's UCS (#1670).
let enter_viewport_task = self.tabs[i]
.scene
.saved_entered_viewport()
.map_or_else(Task::none, |handle| Task::done(Message::EnterViewport(handle)));
// The tab may have been reused for the newly opened file. Any Plot
// dialog cache belongs to the previous/startup drawing and must not
@@ -2314,6 +2333,7 @@ impl OpenCADStudio {
interaction_task,
startup_script_task,
sheet_set_task,
enter_viewport_task,
])
}
@@ -2413,7 +2433,7 @@ impl OpenCADStudio {
let mut skipped = 0usize;
let mut pending: Vec<(crate::app::WebSaveallEntry, codec::CadDocument)> = Vec::new();
for t in 0..self.tabs.len() {
if self.tabs[t].is_start {
if self.tabs[t].is_start || self.tabs[t].block_test.is_some() {
continue;
}
let Some(path) = self.tabs[t].current_path.clone() else {
@@ -2583,6 +2603,41 @@ impl OpenCADStudio {
)
}
pub(super) fn on_fbx_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
let i = self.active_tab;
// Export uses LOD 0 (full resolution); see StlExportPath above.
let meshes: Vec<crate::scene::model::mesh_model::MeshModel> = self.tabs[i]
.scene
.meshes
.values()
.filter_map(|s| s.lods.first().cloned())
.collect();
let header = &self.tabs[i].scene.document.header;
// Centimetres per drawing unit (INSUNITS); unitless metric drawings
// are taken as millimetres, imperial ones as inches.
let unit_cm = match header.insertion_units {
1 => 2.54,
2 => 30.48,
4 => 0.1,
5 => 1.0,
6 => 100.0,
14 => 10.0,
_ if header.measurement == 1 => 0.1,
_ => 2.54,
};
let worker_path = path.clone();
background_task(
crate::t!("FBX export"),
move || {
let mesh_refs: Vec<_> = meshes.iter().collect();
let bytes = crate::io::fbx::build_fbx(&mesh_refs, unit_cm)
.ok_or_else(|| "no mesh data to export".to_string())?;
write_export(&worker_path, &bytes)
},
move |result| Message::FbxExportFinished(path, result),
)
}
pub(super) fn on_obj_import_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
let tab_id = self.tabs[self.active_tab].id;
let worker_path = path.clone();
@@ -2602,6 +2657,7 @@ impl OpenCADStudio {
if self.tabs[i].active_block_edit.is_none() {
self.tabs[i].scene.sync_camera_to_document();
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
self.tabs[i].scene.sync_active_viewport_to_document();
}
}
@@ -3037,6 +3093,9 @@ impl OpenCADStudio {
set_current_path: bool,
check_external_change: bool,
) -> Task<Message> {
if self.refuse_block_test_save(i) {
return Task::none();
}
let tab_id = self.tabs[i].id;
if self
.pending_native_thumbnail_save
@@ -3705,6 +3764,9 @@ impl OpenCADStudio {
}
pub(super) fn on_save_file(&mut self) -> Task<Message> {
if self.refuse_block_test_save(self.active_tab) {
return Task::none();
}
if self.read_only {
self.command_line.push_error(
crate::t!("Read-only session (--read-only): saving is disabled.").as_ref(),
@@ -3761,6 +3823,9 @@ impl OpenCADStudio {
/// Used by plain Save (QSAVE) on an as-yet-unsaved drawing and by the
/// save-before-close flow — the version picker is reserved for Save As.
pub(in crate::app) fn save_with_default_format(&mut self, tab_idx: usize) -> Task<Message> {
if self.refuse_block_test_save(tab_idx) {
return Task::none();
}
self.active_tab = tab_idx;
// Every caller of this entry (Save/QSAVE, the save-before-close
// flow) is plain-save semantics: on the web, the tab's retained
@@ -3887,8 +3952,10 @@ impl OpenCADStudio {
// handle is secured at this gesture moment (or reused from a
// previous pick — retained handles need no activation to write)
// and only the write happens later.
// A registered host save hook takes the bytes through the
// download path, so no file picker opens.
let route = crate::io::fs_access::save_route(
crate::io::fs_access::supported(),
crate::io::fs_access::supported() && crate::sys::save_hook().is_none(),
self.save_plain_reuse_fs_handle,
crate::io::fs_access::handle_ext(tab_id).as_deref(),
ext,
@@ -4151,7 +4218,10 @@ impl OpenCADStudio {
pub(super) fn on_autosave(&mut self) -> Task<Message> {
let mut tasks = Vec::new();
for i in 0..self.tabs.len() {
if !self.tabs[i].dirty || self.active_save_jobs.contains_key(&self.tabs[i].id) {
if self.tabs[i].block_test.is_some()
|| !self.tabs[i].dirty
|| self.active_save_jobs.contains_key(&self.tabs[i].id)
{
continue;
}
self.prepare_snapshot_save(i);
@@ -4186,7 +4256,7 @@ impl OpenCADStudio {
}
let mut pending = Vec::new();
for i in 0..self.tabs.len() {
if !self.tabs[i].dirty {
if self.tabs[i].block_test.is_some() || !self.tabs[i].dirty {
continue;
}
// Same pre-serialize state sync the web Save flow performs
@@ -4513,7 +4583,7 @@ impl OpenCADStudio {
let mut names: Vec<String> = Vec::new();
let mut active = 0;
for (index, tab) in self.tabs.iter().enumerate() {
if tab.is_start {
if tab.is_start || tab.block_test.is_some() {
continue;
}
if index == self.active_tab {
+80
View File
@@ -0,0 +1,80 @@
//! Ribbon key tips: a lone Alt opens them, typed codes walk the ribbon and
//! Esc goes back one level.
use iced::Task;
use crate::app::{Message, OpenCADStudio};
use crate::ui::ribbon::{KeyTipInput, KeyTipStep};
impl OpenCADStudio {
pub(in crate::app) fn on_keytip(&mut self, input: KeyTipInput) -> Task<Message> {
match input {
KeyTipInput::AltTap if self.ribbon.keytips_active() => self.hide_keytips(),
KeyTipInput::AltTap => {
if self.active_modal.is_some() || self.clean_screen || self.ribbon_closed {
return Task::none();
}
// A focused field other than the command line keeps Alt.
iced::advanced::widget::operate(
iced::advanced::widget::operation::focusable::count(),
)
.then(|count| {
if count.focused.is_none() {
return Task::done(Message::KeyTip(KeyTipInput::Open { refocus: false }));
}
iced::widget::operation::is_focused(iced::widget::Id::new(
crate::ui::command_line::CMD_INPUT_ID,
))
.map(|command_line| {
if command_line {
Message::KeyTip(KeyTipInput::Open { refocus: true })
} else {
Message::Noop
}
})
})
}
KeyTipInput::Open { refocus } => {
self.ribbon.show_keytips(refocus);
// Typed codes must not reach the command line.
if refocus {
self.unfocus_widgets()
} else {
Task::none()
}
}
KeyTipInput::Hide => self.hide_keytips(),
KeyTipInput::Escape => {
if self.ribbon.keytip_escape() {
Task::none()
} else {
self.hide_keytips()
}
}
KeyTipInput::Char(_) | KeyTipInput::Enter => {
let ch = match &input {
KeyTipInput::Char(ch) => Some(ch.as_str()),
_ => None,
};
match self.ribbon.keytip_type(ch) {
KeyTipStep::Ignore | KeyTipStep::Stay => Task::none(),
KeyTipStep::Open(message) => self.update(message),
KeyTipStep::Run(message) => {
let refocus = self.hide_keytips();
Task::batch([refocus, self.update(message)])
}
}
}
}
}
/// Close the key tips, returning the focus to the command line if it had
/// it.
pub(in crate::app) fn hide_keytips(&mut self) -> Task<Message> {
if self.ribbon.hide_keytips() {
self.focus_cmd_input()
} else {
Task::none()
}
}
}
+517 -158
View File
File diff suppressed because it is too large. Load diff
+253
View File
@@ -0,0 +1,253 @@
//! Read-only object sampling for the selection-filter dialog.
use crate::app::{OpenCADStudio, QSelectCondition, QSelectState};
use crate::command::{CadCommand, CmdResult};
use codec::Handle;
use glam::DVec3;
use iced::Task;
/// The command owns the suspended dialog, so Escape can restore the exact
/// draft, committed conditions, scope and result mode without global pick state.
struct QSelectSampleCommand {
state: Option<Box<QSelectState>>,
tab_id: u64,
}
impl QSelectSampleCommand {
fn finish(&mut self, handle: Option<Handle>) -> CmdResult {
match self.state.take() {
Some(state) => CmdResult::QSelectSample {
state,
tab_id: self.tab_id,
handle,
},
None => CmdResult::Cancel,
}
}
}
impl CadCommand for QSelectSampleCommand {
fn name(&self) -> &'static str {
"FILTER"
}
fn prompt(&self) -> String {
format!("FILTER {}", crate::t!("Select object:"))
}
fn needs_entity_pick(&self) -> bool {
true
}
fn entity_pick_includes_fills(&self) -> bool {
true
}
fn entity_pick_highlights_hover(&self) -> bool {
true
}
fn on_entity_pick(&mut self, handle: Handle, _point: DVec3) -> CmdResult {
if handle.is_null() {
CmdResult::NeedPoint
} else {
self.finish(Some(handle))
}
}
fn on_point(&mut self, _point: DVec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
self.finish(None)
}
fn on_escape(&mut self) -> CmdResult {
self.finish(None)
}
}
fn append_unique_conditions(target: &mut Vec<QSelectCondition>, incoming: Vec<QSelectCondition>) {
for condition in incoming {
let duplicate = target.iter().any(|existing| {
existing.type_filter == condition.type_filter
&& existing.property == condition.property
&& existing.operator == condition.operator
&& existing.value.eq_ignore_ascii_case(&condition.value)
});
if !duplicate {
target.push(condition);
}
}
}
impl OpenCADStudio {
pub(super) fn on_qselect_pick_object(&mut self) -> Task<crate::app::Message> {
// Finish or cancel a condition edit first. Sampling only appends rows.
if self
.qselect
.as_ref()
.is_none_or(|state| state.editing_condition.is_some())
{
return Task::none();
}
let state = self.qselect.take().expect("checked open filter");
// Keep a recoverable copy if another command or a document switch
// interrupts picking instead of ending it through Enter/Escape.
self.qselect_settings = Some((&state).into());
let i = self.active_tab;
let command = QSelectSampleCommand {
state: Some(Box::new(state)),
tab_id: self.tabs[i].id,
};
self.active_modal = None;
self.command_line.input.clear();
self.command_line.set_step_options(Vec::new());
self.command_line.push_info(&command.prompt());
self.tabs[i].scene.selection.borrow_mut().menu.open_at = None;
self.tabs[i].active_cmd = Some(Box::new(command));
Task::none()
}
pub(in crate::app) fn finish_qselect_sample(
&mut self,
mut state: QSelectState,
tab_id: u64,
handle: Option<Handle>,
) {
let i = self.active_tab;
// A handle is only meaningful in its original document.
if self.tabs[i].id != tab_id {
return;
}
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 let Some(handle) = handle {
let conditions = self.tabs[i].scene.qselect_conditions_from_entity(handle);
append_unique_conditions(&mut state.conditions, conditions);
state.error = None;
}
self.qselect = Some(state);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{QSelectMode, QSelectOp, QSelectScope};
fn state() -> QSelectState {
QSelectState {
conditions: Vec::new(),
editing_condition: None,
scope: QSelectScope::CurrentSelection,
available_types: Vec::new(),
available_properties: Vec::new(),
candidate_count: 2,
type_filter: Some("Line".into()),
property: None,
operator: QSelectOp::Eq,
value: "unfinished draft".into(),
mode: QSelectMode::Exclude,
append: false,
error: None,
}
}
#[test]
fn escape_restores_draft_and_scope_without_a_sample() {
let mut command = QSelectSampleCommand {
state: Some(Box::new(state())),
tab_id: 17,
};
let CmdResult::QSelectSample {
state,
tab_id,
handle,
} = command.on_escape()
else {
panic!("Escape must restore the filter");
};
assert_eq!(tab_id, 17);
assert!(handle.is_none());
assert_eq!(state.value, "unfinished draft");
assert!(matches!(state.scope, QSelectScope::CurrentSelection));
assert!(matches!(state.mode, QSelectMode::Exclude));
}
#[test]
fn empty_click_keeps_picker_active_and_enter_restores_dialog() {
let mut command = QSelectSampleCommand {
state: Some(Box::new(state())),
tab_id: 17,
};
assert!(matches!(
command.on_entity_pick(Handle::NULL, DVec3::ZERO),
CmdResult::NeedPoint
));
assert!(matches!(
command.on_enter(),
CmdResult::QSelectSample { handle: None, .. }
));
}
#[test]
fn repeated_sampling_preserves_existing_conditions_without_duplicates() {
let condition = state().draft_condition();
let mut conditions = vec![condition.clone()];
append_unique_conditions(&mut conditions, vec![condition.clone(), condition]);
assert_eq!(conditions.len(), 1);
assert_eq!(conditions[0].value, "unfinished draft");
}
#[test]
fn sample_round_trip_keeps_selection_and_dialog_geometry() {
use codec::entities::Line;
use codec::types::Vector3;
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
let first = app.tabs[i]
.scene
.add_entity(codec::EntityType::Line(Line::from_points(
Vector3::ZERO,
Vector3::new(10.0, 0.0, 0.0),
)));
let sample = app.tabs[i]
.scene
.add_entity(codec::EntityType::Line(Line::from_points(
Vector3::ZERO,
Vector3::new(20.0, 0.0, 0.0),
)));
app.tabs[i].scene.select_entity(first, false);
let selection = app.tabs[i].scene.selected.clone();
let dirty = app.tabs[i].dirty;
app.qselect = Some(state());
app.modal_offset = iced::Vector::new(10.0, 20.0);
app.modal_resize = iced::Vector::new(30.0, 40.0);
let _ = app.on_qselect_pick_object();
assert!(app.qselect.is_none());
let result = app.tabs[i]
.active_cmd
.as_mut()
.unwrap()
.on_entity_pick(sample, DVec3::ZERO);
let _ = app.apply_cmd_result(result);
let restored = app.qselect.as_ref().unwrap();
assert!(!restored.conditions.is_empty());
assert_eq!(restored.value, "unfinished draft");
assert!(matches!(restored.scope, QSelectScope::CurrentSelection));
assert_eq!(app.tabs[i].scene.selected, selection);
assert_eq!(app.tabs[i].dirty, dirty);
assert_eq!(app.modal_offset, iced::Vector::new(10.0, 20.0));
assert_eq!(app.modal_resize, iced::Vector::new(30.0, 40.0));
assert!(app.tabs[i].active_cmd.is_none());
let count = restored.conditions.len();
let _ = app.on_qselect_pick_object();
let result = app.tabs[i].active_cmd.as_mut().unwrap().on_escape();
let _ = app.apply_cmd_result(result);
assert_eq!(app.qselect.as_ref().unwrap().conditions.len(), count);
assert_eq!(app.tabs[i].scene.selected, selection);
}
}
+12
View File
@@ -1190,6 +1190,18 @@ impl OpenCADStudio {
crate::ui::window::gradient_editor::GradientMsg::Color(index, color),
)))
}
Some(crate::app::ColorPickTarget::ViewBackground(slot)) => {
let rgb = match color {
codec::types::Color::Rgb { r, g, b } => Some([r, g, b]),
codec::types::Color::Index(index) => {
codec::types::aci_table::aci_to_rgb(index).map(|(r, g, b)| [r, g, b])
}
_ => None,
};
rgb.map(|rgb| {
Message::PdfDialog(crate::ui::window::pdf_dialogs::PdfDialogMsg::BgColor(slot, rgb))
})
}
Some(crate::app::ColorPickTarget::GraphicAttributesLine) => Some(
Message::GraphicAttributes(GraphicAttributesMsg::LineColor(color)),
),
+306 -22
View File
@@ -340,6 +340,10 @@ impl OpenCADStudio {
base: glam::DVec3,
allowed: bool,
) -> Option<glam::DVec3> {
if let Some(direction) = self.angle_override.filter(|_| allowed) {
self.axis_lock_dir = Some(direction);
return self.axis_lock_dir;
}
if self.shift_down && allowed {
if self.axis_lock_dir.is_none() {
let ucs = self.tabs[tab].ucs_xform();
@@ -1084,12 +1088,37 @@ impl OpenCADStudio {
// viewport, the picker drives that viewport entity's own
// render mode; the model-layout tab style is untouched.
if self.tabs[i].scene.set_active_viewport_render_mode(mode) {
self.tabs[i]
.scene
.set_active_visual_style_named(crate::modules::view::visual_style::style_name_for(mode));
self.tabs[i].scene.bump_geometry_no_blocks();
self.tabs[i].dirty = true;
self.command_line
.push_output(crate::tf!("Viewport visual style: {label}").as_ref());
return Task::none();
}
self.apply_render_mode(i, mode, label);
// The viewport record carries the style the mode is saved as.
self.tabs[i]
.scene
.set_active_visual_style_named(crate::modules::view::visual_style::style_name_for(mode));
self.command_line
.push_output(crate::tf!("Visual style: {label}").as_ref());
// The point cloud move gizmo follows the visual style.
if i == self.active_tab {
self.refresh_selected_grips();
}
Task::none()
}
/// The model tab's visual style, without a message.
pub(crate) fn apply_render_mode(
&mut self,
i: usize,
mode: codec::entities::ViewportRenderMode,
label: &str,
) {
use codec::entities::ViewportRenderMode as M;
self.tabs[i].render_mode = mode;
// Write the style onto the active Model tile alone so it
// sticks when that tile loses focus and the other tiles keep
@@ -1105,13 +1134,6 @@ impl OpenCADStudio {
// pipeline keys its upload cache off `geometry_epoch`.
self.tabs[i].scene.bump_geometry_no_blocks();
self.tabs[i].dirty = true;
self.command_line
.push_output(crate::tf!("Visual style: {label}").as_ref());
// The point cloud move gizmo follows the visual style.
if i == self.active_tab {
self.refresh_selected_grips();
}
Task::none()
}
pub(super) fn on_cursor_moved(
@@ -1211,6 +1233,9 @@ impl OpenCADStudio {
return Task::none();
}
let i = self.active_tab;
if let Some(task) = self.start_drag_move(i, p) {
return Task::batch([task, self.on_viewport_move(p)]);
}
self.constraint_glyph_tooltip = None;
let constraint_hover = self
.constraint_glyph_under(i, p)
@@ -1507,14 +1532,36 @@ impl OpenCADStudio {
// In a viewport, pick the cursor's model point with the
// viewport camera directly; otherwise project on the paper
// sheet and map to model. Either way `raw` is model space.
//
// A model-space grip moves in the drawing plane through where it
// started (the UCS XY plane at the grip's own elevation). The
// plane facing the screen through the orbit target put the point
// somewhere else in depth in any tilted view, so a circle jumped
// to another radius and a vertex left its plane (#1033). Seen
// edge-on that plane cannot be hit; the screen plane takes over.
let grip_plane = self.tabs[i].editing_model_space().then(|| {
let normal = self.tabs[i]
.active_ucs
.as_ref()
.map_or(glam::DVec3::Z, ucs_z_axis);
(normal, grip.origin_world)
});
let pick = |cam: &crate::scene::view::camera::Camera| match grip_plane {
Some((normal, origin))
if (cam.rotation * glam::Vec3::NEG_Z)
.as_dvec3()
.dot(normal)
.abs()
> 0.05 =>
{
cam.pick_on_plane(p, bounds, normal.as_vec3(), origin)
}
_ => cam.pick_on_target_plane(p, bounds),
};
let raw = match &edit_cam {
Some(cam) => cam.pick_on_target_plane(p, bounds),
Some(cam) => pick(cam),
None => {
let paper = self.tabs[i]
.scene
.camera
.borrow()
.pick_on_target_plane(p, bounds);
let paper = pick(&self.tabs[i].scene.camera.borrow());
self.tabs[i].scene.paper_to_model(paper)
}
};
@@ -1629,7 +1676,7 @@ impl OpenCADStudio {
} else {
self.tabs[i].scene.hit_test_wires()
};
let snap_candidates = self.tabs[i].scene.interaction_candidates_near(
let mut snap_candidates = self.tabs[i].scene.interaction_candidates_near(
all_wires,
raw,
view_rot,
@@ -1637,6 +1684,16 @@ impl OpenCADStudio {
bounds,
self.snapper.osnap_radius_px,
);
// A solid stays drawn while its grip moves, so its own edges are
// still in the set: the grip would snap back onto the shape it is
// reshaping (a cone's base radius landed on its own side, #1033).
let edited = &self.grip_preview_handles;
if !edited.is_empty() {
snap_candidates.exclude_wires(|wire| {
crate::scene::Scene::handle_from_wire_name(&wire.name)
.is_some_and(|handle| edited.contains(&handle))
});
}
// The engaged grip is the rubber-band origin. Perpendicular
// snapping must drop its foot from this point, including when a
// hot-grip set is moved by the same drag vector.
@@ -2606,7 +2663,15 @@ impl OpenCADStudio {
.map(|snap| snap.world)
.unwrap_or(cursor_world);
axis_lock_apply(point, base, dir)
} else if let Some(h) = otrack_hit {
} else if let Some(h) = otrack_hit.filter(|_| {
// A real object snap must win over tracking alignment.
// Otherwise the endpoint glyph can appear at the
// corner while the preview/accepted point follows the
// tracking line instead.
!self.tabs[i].snap_result.is_some_and(|s| {
s.snap_type != crate::snap::SnapType::Grid
})
}) {
h.aligned
} else {
// Snap runs in model space (viewport camera or the
@@ -3526,6 +3591,68 @@ impl OpenCADStudio {
Some((constraint.kind, constraint.refs.clone()))
}
/// Whether a selected object lies under `p` (tile-local).
fn selected_object_under(
&self,
i: usize,
p: Point,
bounds: iced::Rectangle,
edit_cam: &Option<crate::scene::view::camera::Camera>,
) -> bool {
let (view_rot, eye, wires) = self.pick_view(i, edit_cam, bounds);
let aperture = crate::ui::overlay::pick_box_aperture_px(self.pick_box);
let world = self.cursor_model_point(i, edit_cam, p, bounds);
let scene = &self.tabs[i].scene;
let candidates = scene
.interaction_hover_candidates_near(wires, world, view_rot, eye, bounds, aperture * 2.0);
scene::pick::hit_test::click_hit(
p,
&candidates,
view_rot,
eye,
bounds,
scene.document.header.lineweight_display,
aperture,
&scene.draw_depth_map(),
)
.and_then(Scene::handle_from_wire_name)
.is_some_and(|handle| scene.selected.contains(&handle))
}
/// Once a press on a selected object has moved far enough, MOVE (COPY
/// with Ctrl) runs on the selection from the grabbed point, so the drop
/// gets the command's preview, snaps and ortho.
fn start_drag_move(&mut self, i: usize, p: Point) -> Option<Task<Message>> {
let (base, false) = self.drag_move? else {
return None;
};
{
let mut sel = self.tabs[i].scene.selection.borrow_mut();
let press = sel.input.left_press_pos?;
let held_ms = sel
.input
.left_press_time
.map_or(u128::MAX, |time| time.elapsed().as_millis());
let (dx, dy) = (p.x - press.x, p.y - press.y);
if !sel.input.left_down || held_ms < POLY_START_DELAY_MS || dx * dx + dy * dy <= 9.0 {
return None;
}
sel.input.left_dragging = true;
sel.gesture.clear_left();
}
self.drag_move = Some((base, true));
let command = if self.ctrl_down { "COPY" } else { "MOVE" };
let started = self.update(Message::Command(command.to_string()));
if self.tabs[i].active_cmd.is_none() {
self.drag_move = None;
return Some(started);
}
Some(Task::batch([
started,
self.feed_command(crate::command::StepInput::Point(base)),
]))
}
pub(super) fn on_viewport_left_press(&mut self) -> Task<Message> {
let i = self.active_tab;
self.block_palette.pressed = None;
@@ -3895,6 +4022,39 @@ impl OpenCADStudio {
}
}
// Ctrl+click on a polyline picks the segment under the cursor; the
// edit commands then move just that part.
if self.ctrl_down && self.tabs[i].active_cmd.is_none() {
let (view_rot, eye) = {
let cam = self.tabs[i].scene.camera.borrow();
(cam.view_proj_rte(bounds), cam.eye())
};
let wires = self.tabs[i].scene.hit_test_wires();
let hit = scene::pick::hit_test::click_hit(
p,
&*wires,
view_rot,
eye,
bounds,
self.tabs[i].scene.document.header.lineweight_display,
crate::ui::overlay::pick_box_aperture_px(self.pick_box),
&self.tabs[i].scene.draw_depth_map(),
)
.and_then(Scene::handle_from_wire_name)
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
drop(wires);
let point = self.tabs[i].last_cursor_world;
if hit.is_some_and(|handle| self.tabs[i].scene.toggle_sub_segment(handle, point)) {
self.tabs[i]
.scene
.selection
.borrow_mut()
.clear_left_selection_gesture();
self.refresh_properties();
return Task::none();
}
}
// Ctrl+click on a composite solid picks the solid of it under the
// cursor — the one the Properties palette and grips then edit.
if self.ctrl_down
@@ -3939,6 +4099,12 @@ impl OpenCADStudio {
.gesture
.box_anchor
.is_none();
// A press on a selected object may become a drag of the selection.
self.drag_move = (self.tabs[i].active_cmd.is_none()
&& !self.shift_down
&& !self.tabs[i].scene.selected.is_empty()
&& self.selected_object_under(i, p, bounds, &edit_cam))
.then_some((self.tabs[i].last_cursor_world, false));
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.input.left_down = true;
// Stored in full-canvas space (like ViewportMove's cursor and
@@ -3962,6 +4128,26 @@ impl OpenCADStudio {
if let Some(item) = self.take_block_drop() {
return self.drop_block(item);
}
// A dragged selection drops where the button comes up: the release
// is the MOVE / COPY destination click, and a COPY places one copy.
if let Some((_, true)) = self.drag_move.take() {
{
// Starting the command reset the press; the drop reads as a
// click at the cursor.
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.input.left_down = true;
sel.input.left_dragging = false;
}
let placed = self.on_viewport_left_release();
let copying = self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.name() == "COPY");
if !copying {
return placed;
}
return Task::batch([placed, self.feed_command(crate::command::StepInput::Enter)]);
}
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
command.set_ctrl(self.ctrl_down);
command.set_shift(self.shift_down);
@@ -4320,7 +4506,11 @@ impl OpenCADStudio {
} else {
None
};
if let Some(h) = otrack {
if let Some(h) = otrack.filter(|_| {
// Match the hover path: never override a genuine OSNAP
// endpoint/intersection with a tracking projection.
!snap_hit.is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid)
}) {
pt = h.aligned;
if clamp_world_xy {
pt.z = 0.0;
@@ -5765,6 +5955,80 @@ properties={:.1}ms picked={}",
/// Pinch the active view to zoom, as reported by the trackpad monitor
/// (`src/input/trackpad.rs`). The pivot is the cursor, like the wheel, and
/// the direction is the fingers' own, so ZOOMWHEEL does not apply to it.
/// A touch screen finger event: multi-finger gestures pan, zoom and
/// orbit the view (see `input::touch`).
pub(super) fn on_touch(&mut self, event: iced::touch::Event) -> Task<Message> {
use crate::input::touch::TouchAction;
let i = self.active_tab;
if self.tabs[i].is_start {
return Task::none();
}
// A lone finger drives the cursor, so where it is in the window and
// in the drawing area tells the drawing area's offset.
if let iced::touch::Event::FingerMoved { position, .. } = event {
let cursor = self.tabs[i].scene.selection.borrow().input.last_move_pos;
if let Some(cursor) = cursor.filter(|_| self.touch.fingers() == 1) {
self.touch_offset = Some(position - cursor);
}
}
let mut tasks = Vec::new();
for action in self.touch.event(event) {
match action {
TouchAction::Cancel => {
self.tabs[i]
.scene
.selection
.borrow_mut()
.clear_left_selection_gesture();
self.drag_move = None;
self.tabs[i].scene.remember_current_view();
self.clear_navigation_hover(i);
}
TouchAction::Pan(delta) => {
self.pan_active_view(i, delta.x, delta.y, Instant::now());
}
TouchAction::Zoom { factor, centre } => {
// Zoom about the fingers once the drawing area's place in
// the window is known; until then about the first finger.
if let Some(offset) = self.touch_offset {
self.tabs[i].scene.selection.borrow_mut().input.last_move_pos =
Some(centre - offset);
}
tasks.push(self.zoom_view_at_cursor(pinch_zoom_steps(factor - 1.0)));
}
TouchAction::Orbit(delta) => {
if self.tabs[i].scene.active_viewport.is_some() {
self.tabs[i].scene.orbit_active_viewport(delta.x, delta.y);
} else if self.tabs[i].scene.current_layout == "Model" {
let scene = &self.tabs[i].scene;
let (vw, vh) = scene.selection.borrow().view.vp_size;
let bounds = scene.active_model_tile_bounds(vw, vh);
let pivot = scene
.orbit_pivot()
.or_else(|| scene.view_center_surface_pivot(bounds))
.unwrap_or_else(|| scene.camera.borrow().target);
self.tabs[i].scene.refresh_projection_bounds();
self.tabs[i]
.scene
.camera
.borrow_mut()
.orbit(delta.x, delta.y, Some(pivot));
}
self.tabs[i].scene.camera_generation += 1;
}
TouchAction::Tap(2) => {
tasks.push(self.update(Message::ViewportRightPress));
tasks.push(self.update(Message::ViewportRightRelease));
}
TouchAction::Tap(_) => {
tasks.push(Task::done(Message::Command("ZOOM E".to_string())));
}
TouchAction::SwipeUp => tasks.push(self.apply_view_preset("SEISO")),
}
}
Task::batch(tasks)
}
pub(super) fn on_pinch_zoom(&mut self, magnification: f32) -> Task<Message> {
if magnification == 0.0 {
return Task::none();
@@ -6301,6 +6565,12 @@ properties={:.1}ms picked={}",
}
pub(crate) fn on_layout_switch(&mut self, name: String) -> Task<Message> {
if self.tabs[self.active_tab].active_block_edit.is_some() {
return self.request_block_edit_close(
self.active_tab,
crate::app::block_editor::BlockEditContinuation::Layout(name),
);
}
self.on_layout_switch_inner(name, false)
}
@@ -6363,6 +6633,12 @@ properties={:.1}ms picked={}",
/// define a view and then returns to its paper layout. This is the only
/// layout transition allowed to preserve an active command.
pub(crate) fn on_layout_switch_preserving_command(&mut self, name: String) -> Task<Message> {
if self.tabs[self.active_tab].active_block_edit.is_some() {
return self.request_block_edit_close(
self.active_tab,
crate::app::block_editor::BlockEditContinuation::Layout(name),
);
}
self.on_layout_switch_inner(name, true)
}
@@ -6391,10 +6667,10 @@ properties={:.1}ms picked={}",
&& self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.name() == "MVIEW");
.is_some_and(|command| matches!(command.name(), "MVIEW" | "MVSETUP"));
if context_changed {
if preserve_active_command {
// MVIEW keeps its command-owned step data, but all host-owned
// MVIEW and MVSETUP keep their step data, but all host-owned
// cursor/snap/dynamic-input state belongs to the old space.
self.reset_space_interaction_state();
}
@@ -6520,6 +6796,7 @@ properties={:.1}ms picked={}",
.as_ref()
.map(|style| style.name.clone())
.unwrap_or_default();
self.tabs[i].scene.new_layout_plot_style = plot_style.clone();
for obj in self.tabs[i].scene.document.objects.values_mut() {
if let codec::objects::ObjectType::Layout(l) = obj {
if l.name == new_name {
@@ -6534,10 +6811,17 @@ properties={:.1}ms picked={}",
self.tabs[i].scene.ensure_sheet_viewport(&new_name);
let switch_task = self.on_layout_switch(new_name.clone());
self.tabs[i].scene.fit_all();
self.command_line.push_output(
crate::tf!("Layout \"{new_name}\" created — use MVIEW to add a viewport")
.as_ref(),
);
// Opened, it already has its first viewport unless
// LAYOUTCREATEVIEWPORT is off.
if crate::scene::layout_create_viewport() {
let name = new_name.clone();
self.command_line.push_output(crate::tf!("Layout '{name}' created.").as_ref());
} else {
self.command_line.push_output(
crate::tf!("Layout \"{new_name}\" created — use MVIEW to add a viewport")
.as_ref(),
);
}
// The option that greets every new layout with its page setup.
let switch_task = if self.plot_dialog.page_setup_on_new_layout {
Task::batch([switch_task, self.on_plot_dialog_open()])