forked from 1b-agent/opencadstudio-upstream
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8
This commit is contained in:
231 files changed
+32217
-3423
No files matched your search
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()])
|
||||
|
||||
Reference in new issue
Block a user