forked from 1b-agent/opencadstudio-upstream
Merge main into Pallet-placement
Resolved conflicts: - app/update/mod.rs: keep the dock's dock_open_at_default for the XREF manager; take main's stable xref row identity (row_index(&id)). - app/view/mod.rs: keep the edge layout from app::dock; drop main's copy of the old edge-stack code (main only reformatted it there). - ui/window/layers.rs: keep main's new visible_indices next to the branch's view_panel. Files the branch only reformatted take main's version. Adds the Lao (lo-LA) translations for the new dock texts. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt
This commit is contained in:
136 files changed
+12408
-949
No files matched your search
+2
-1
@@ -80,7 +80,7 @@ const WEB_ALIAS_KEY: &str = "opencadstudio.aliases";
|
||||
/// migrated forward (see `introduced_at` / `migrate_aliases`). Version 1 shipped
|
||||
/// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases; version 3
|
||||
/// introduced HB (HATCHTOBACK); version 4 introduced ER (EXTERNALREFERENCES).
|
||||
const DEFAULT_ALIASES_VERSION: u32 = 5;
|
||||
const DEFAULT_ALIASES_VERSION: u32 = 6;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const WEB_ALIAS_VERSION_KEY: &str = "opencadstudio.aliases.version";
|
||||
@@ -100,6 +100,7 @@ fn introduced_at(version: u32) -> &'static [(&'static str, &'static str)] {
|
||||
3 => &[("HB", "HATCHTOBACK")],
|
||||
4 => &[("ER", "EXTERNALREFERENCES")],
|
||||
5 => &[("-ATT", "-ATTDEF")],
|
||||
6 => &[("VV", "QLAYER")],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
+37
-1
@@ -876,6 +876,42 @@ impl OpenCADStudio {
|
||||
for h in explicit {
|
||||
self.tabs[i].scene.select_entity(h, false);
|
||||
}
|
||||
// Or by a window / crossing box in world XY, through the
|
||||
// viewport's own box test.
|
||||
if let Some(window) = req["window"].as_array() {
|
||||
let corner = |index: usize| window.get(index).and_then(Value::as_f64);
|
||||
let (Some(x1), Some(y1), Some(x2), Some(y2)) =
|
||||
(corner(0), corner(1), corner(2), corner(3))
|
||||
else {
|
||||
return err("select: window needs [x1, y1, x2, y2]");
|
||||
};
|
||||
let crossing = req["crossing"].as_bool().unwrap_or(false);
|
||||
let (vw, vh) = self.tabs[i].scene.selection.borrow().view.vp_size;
|
||||
let tile = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
|
||||
let bounds = iced::Rectangle {
|
||||
width: tile.width,
|
||||
height: tile.height,
|
||||
..iced::Rectangle::default()
|
||||
};
|
||||
let screen = |x: f64, y: f64| {
|
||||
let camera = self.tabs[i].scene.camera.borrow();
|
||||
camera.project(glam::DVec3::new(x, y, 0.0), bounds)
|
||||
};
|
||||
let (Some(a), Some(p)) = (screen(x1, y1), screen(x2, y2)) else {
|
||||
return err("select: the window is not in view");
|
||||
};
|
||||
let (mut handles, _) = self.box_selection_handles(
|
||||
i,
|
||||
&None,
|
||||
bounds,
|
||||
iced::Point::new(a.x, a.y),
|
||||
iced::Point::new(p.x, p.y),
|
||||
crossing,
|
||||
);
|
||||
handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h));
|
||||
self.tabs[i].scene.select_entities(&handles);
|
||||
self.tabs[i].scene.expand_selection_for_groups(&handles);
|
||||
}
|
||||
// Or by type / layer.
|
||||
let type_filter = req["type"].as_str();
|
||||
let layer_filter = req["layer"].as_str();
|
||||
@@ -1790,7 +1826,7 @@ mod tests {
|
||||
viewport.id = 2;
|
||||
viewport.width = 100.0;
|
||||
viewport.height = 50.0;
|
||||
viewport.status.is_on = true;
|
||||
viewport.turn_on();
|
||||
scene.add_entity(codec::EntityType::Viewport(viewport));
|
||||
for entity in scene.document.entities_mut() {
|
||||
if let codec::EntityType::Viewport(viewport) = entity {
|
||||
|
||||
@@ -308,11 +308,23 @@ impl OpenCADStudio {
|
||||
// populate_missing_meshes_from_document / bumps … CALLER-SIDE
|
||||
let clipboard = self.clipboard.clone();
|
||||
let deps = self.clipboard_deps.clone();
|
||||
// Into the space new entities go: the edited block, the layout's
|
||||
// paper space (no viewport active), or model space.
|
||||
let scene = &self.tabs[i].scene;
|
||||
let layout = scene.current_layout.clone();
|
||||
let space = match scene.block_edit_block {
|
||||
Some(block) => PasteSpace::Block(block),
|
||||
None if layout != "Model" && scene.active_viewport.is_none() => {
|
||||
PasteSpace::Layout(&layout)
|
||||
}
|
||||
None => PasteSpace::Model,
|
||||
};
|
||||
let (by_index, handle_map) = paste_entities_kernel(
|
||||
&mut self.tabs[i].scene.document,
|
||||
&clipboard,
|
||||
&deps,
|
||||
translate.as_ref(),
|
||||
space,
|
||||
);
|
||||
// Batched equivalent of `add_entity`'s per-add bump (same pattern as
|
||||
// `Scene::add_entities`): fresh top-level solids tessellate once here
|
||||
@@ -473,11 +485,22 @@ pub(crate) fn merge_block_defs(
|
||||
/// `recreate_groups`, `duplicate_parametric_constraints_for`,
|
||||
/// `sync_displayed_annotation_context`, undo/dirty/selection/echo/panels).
|
||||
/// See the per-helper report in `finalize_paste`.
|
||||
/// Where pasted entities go, the way `Scene::add_entity` routes a new one.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum PasteSpace<'a> {
|
||||
Model,
|
||||
/// A layout's paper space, when no viewport of it is active.
|
||||
Layout(&'a str),
|
||||
/// The block definition being edited (BEDIT).
|
||||
Block(Handle),
|
||||
}
|
||||
|
||||
pub(crate) fn paste_entities_kernel(
|
||||
doc: &mut codec::CadDocument,
|
||||
entities: &[codec::EntityType],
|
||||
deps: &crate::app::ClipboardDeps,
|
||||
translate: Option<&crate::command::EntityTransform>,
|
||||
space: PasteSpace,
|
||||
) -> (Vec<Handle>, rustc_hash::FxHashMap<Handle, Handle>) {
|
||||
// Shared table-record merge (also used by `merge_dependencies`): recreate
|
||||
// missing records with fresh handles from the target document.
|
||||
@@ -515,7 +538,7 @@ pub(crate) fn paste_entities_kernel(
|
||||
}
|
||||
}
|
||||
}
|
||||
add_entity_clone_doc(doc, entity)
|
||||
add_entity_clone_doc(doc, entity, space)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -557,6 +580,8 @@ pub(crate) fn paste_entities_kernel(
|
||||
// A pasted associative hatch follows the pasted boundary, or drops the
|
||||
// association when the boundary was not pasted with it. (#1370)
|
||||
crate::scene::remap_hatch_associations(doc, &handle_map);
|
||||
// Reactors on the source drawing's objects do not travel with the paste.
|
||||
crate::scene::remap_copied_reactors(doc, &handle_map);
|
||||
let leader_links: Vec<(Handle, Handle)> = entities
|
||||
.iter()
|
||||
.filter_map(|source| {
|
||||
@@ -583,9 +608,13 @@ pub(crate) fn paste_entities_kernel(
|
||||
/// `Scene::add_entity_clone` — fresh inline sub-handles (INSERT attributes,
|
||||
/// 3D-polyline vertices; `reset_clone_subhandles` is `scene`-private so the
|
||||
/// two lines are mirrored here), NULL top-level/owner handles, then
|
||||
/// `doc.add_entity`. The `entity_mode` default (Model vs paper space) and all
|
||||
/// tessellation/render caching stay caller-side in the Scene shell.
|
||||
fn add_entity_clone_doc(doc: &mut codec::CadDocument, mut entity: codec::EntityType) -> Handle {
|
||||
/// adds it to `space`. Tessellation/render caching stays caller-side in the
|
||||
/// Scene shell.
|
||||
fn add_entity_clone_doc(
|
||||
doc: &mut codec::CadDocument,
|
||||
mut entity: codec::EntityType,
|
||||
space: PasteSpace,
|
||||
) -> Handle {
|
||||
match &mut entity {
|
||||
codec::EntityType::Insert(ins) => {
|
||||
for att in ins.attributes.iter_mut() {
|
||||
@@ -601,7 +630,15 @@ fn add_entity_clone_doc(doc: &mut codec::CadDocument, mut entity: codec::EntityT
|
||||
}
|
||||
entity.common_mut().handle = Handle::NULL;
|
||||
entity.common_mut().owner_handle = Handle::NULL;
|
||||
doc.add_entity(entity).unwrap_or(Handle::NULL)
|
||||
match space {
|
||||
PasteSpace::Model => doc.add_entity(entity),
|
||||
PasteSpace::Layout(layout) => doc.add_entity_to_layout(entity, layout),
|
||||
PasteSpace::Block(block) => {
|
||||
entity.common_mut().owner_handle = block;
|
||||
doc.add_entity(entity)
|
||||
}
|
||||
}
|
||||
.unwrap_or(Handle::NULL)
|
||||
}
|
||||
|
||||
/// Document-level verbatim block-definition recreate: the `&mut CadDocument`
|
||||
@@ -1149,8 +1186,13 @@ mod paste_entities_kernel_tests {
|
||||
let mut dst = codec::CadDocument::new();
|
||||
let delta = glam::DVec3::new(5.0, 7.0, 0.0);
|
||||
let translate = crate::command::EntityTransform::Translate(delta);
|
||||
let (handles, handle_map) =
|
||||
paste_entities_kernel(&mut dst, &entities, &deps, Some(&translate));
|
||||
let (handles, handle_map) = paste_entities_kernel(
|
||||
&mut dst,
|
||||
&entities,
|
||||
&deps,
|
||||
Some(&translate),
|
||||
crate::app::PasteSpace::Model,
|
||||
);
|
||||
|
||||
assert_eq!(handles.len(), 1);
|
||||
let pasted = handles[0];
|
||||
@@ -1189,7 +1231,13 @@ mod paste_entities_kernel_tests {
|
||||
let (entities, deps) = copy_to_clipboard_kernel(&seed, &[leader_handle, note]);
|
||||
|
||||
let mut dst = codec::CadDocument::new();
|
||||
let (handles, _) = paste_entities_kernel(&mut dst, &entities, &deps, None);
|
||||
let (handles, _) = paste_entities_kernel(
|
||||
&mut dst,
|
||||
&entities,
|
||||
&deps,
|
||||
None,
|
||||
crate::app::PasteSpace::Model,
|
||||
);
|
||||
|
||||
assert_eq!(handles.len(), 2);
|
||||
let pasted_leader = match dst.get_entity(handles[0]) {
|
||||
@@ -1230,7 +1278,13 @@ mod paste_entities_kernel_tests {
|
||||
|
||||
let mut dst = codec::CadDocument::new();
|
||||
let before: Vec<Handle> = dst.objects.keys().copied().collect();
|
||||
let (handles, _) = paste_entities_kernel(&mut dst, &entities, &deps, None);
|
||||
let (handles, _) = paste_entities_kernel(
|
||||
&mut dst,
|
||||
&entities,
|
||||
&deps,
|
||||
None,
|
||||
crate::app::PasteSpace::Model,
|
||||
);
|
||||
|
||||
assert_eq!(handles.len(), 1);
|
||||
let pasted = handles[0];
|
||||
@@ -1294,8 +1348,13 @@ mod headless_reuse_proof_tests {
|
||||
|
||||
let mut doc_b = codec::CadDocument::new();
|
||||
let translate = crate::command::EntityTransform::Translate(glam::DVec3::new(5.0, 7.0, 0.0));
|
||||
let (handles, handle_map) =
|
||||
paste_entities_kernel(&mut doc_b, &entities, &deps, Some(&translate));
|
||||
let (handles, handle_map) = paste_entities_kernel(
|
||||
&mut doc_b,
|
||||
&entities,
|
||||
&deps,
|
||||
Some(&translate),
|
||||
crate::app::PasteSpace::Model,
|
||||
);
|
||||
|
||||
assert_eq!(handles.len(), 1);
|
||||
let pasted = handles[0];
|
||||
@@ -1347,4 +1406,58 @@ mod headless_reuse_proof_tests {
|
||||
other => panic!("expected matched Line, got {other:?}"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn paste_owner_override_routes_entity_into_block_record() {
|
||||
let mut source = codec::CadDocument::new();
|
||||
|
||||
let line = codec::entities::Line::from_points(
|
||||
codec::types::Vector3::new(0.0, 0.0, 0.0),
|
||||
codec::types::Vector3::new(10.0, 0.0, 0.0),
|
||||
);
|
||||
let source_handle = source
|
||||
.add_entity(codec::EntityType::Line(line))
|
||||
.expect("source line");
|
||||
|
||||
let (entities, deps) = copy_to_clipboard_kernel(&source, &[source_handle]);
|
||||
|
||||
let mut target = codec::CadDocument::new();
|
||||
|
||||
let block_handle = target.allocate_handle();
|
||||
let mut block = codec::tables::BlockRecord::new("BEDIT_TARGET");
|
||||
block.handle = block_handle;
|
||||
target
|
||||
.block_records
|
||||
.add(block)
|
||||
.expect("target block record");
|
||||
|
||||
let (handles, _) = paste_entities_kernel(
|
||||
&mut target,
|
||||
&entities,
|
||||
&deps,
|
||||
None,
|
||||
crate::app::PasteSpace::Block(block_handle),
|
||||
);
|
||||
|
||||
assert_eq!(handles.len(), 1);
|
||||
let pasted = handles[0];
|
||||
assert!(!pasted.is_null());
|
||||
|
||||
let entity = target.get_entity(pasted).expect("pasted entity must exist");
|
||||
|
||||
assert_eq!(
|
||||
entity.common().owner_handle,
|
||||
block_handle,
|
||||
"paste inside BEDIT must belong to the edited block record"
|
||||
);
|
||||
|
||||
let record = target
|
||||
.block_records
|
||||
.get("BEDIT_TARGET")
|
||||
.expect("block record must still exist");
|
||||
|
||||
assert!(
|
||||
record.entity_handles.contains(&pasted),
|
||||
"pasted entity must be registered as a member of the edited block"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ mod utilities;
|
||||
mod viewport;
|
||||
|
||||
pub(crate) use clipboard::{
|
||||
copy_to_clipboard_kernel, paste_entities_kernel, remap_ext_subtree_reference,
|
||||
copy_to_clipboard_kernel, paste_entities_kernel, remap_ext_subtree_reference, PasteSpace,
|
||||
};
|
||||
|
||||
impl OpenCADStudio {
|
||||
@@ -204,6 +204,7 @@ impl OpenCADStudio {
|
||||
if was_active && self.tabs[i].active_cmd.is_none() {
|
||||
self.tabs[i].scene.set_hover_highlight(None);
|
||||
self.command_line.set_step_options(Vec::new());
|
||||
self.command_line.clear_contextual_suggestions();
|
||||
self.restore_add_selected_defaults();
|
||||
self.tabs[i].pending_pause_tokens = None;
|
||||
}
|
||||
|
||||
@@ -472,6 +472,8 @@ impl OpenCADStudio {
|
||||
.collect::<Vec<_>>();
|
||||
// Group 294: a base point the user picked.
|
||||
let picked_base = options.base_point.is_some();
|
||||
let several = profiles.len() > 1;
|
||||
let picked = options;
|
||||
let options = sweep_model::sweep_selection_options(&selection, options);
|
||||
let pending = if profiles.is_empty() {
|
||||
None
|
||||
@@ -524,7 +526,10 @@ impl OpenCADStudio {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let deletes_path = crate::app::delobj_deletes_auxiliary(delete_objects, surface);
|
||||
// An associative surface keeps the profile and path it reads.
|
||||
let associative = surface && sweep_model::surface_associativity();
|
||||
let deletes_path =
|
||||
!associative && crate::app::delobj_deletes_auxiliary(delete_objects, surface);
|
||||
if deletes_path && self.tabs[i].scene.is_layer_locked(path_handle) {
|
||||
failed += 1;
|
||||
continue;
|
||||
@@ -537,6 +542,24 @@ impl OpenCADStudio {
|
||||
created,
|
||||
&expressions,
|
||||
);
|
||||
if associative {
|
||||
// A picked base point, or the one several profiles share.
|
||||
let linked = crate::command::SweepOptions {
|
||||
base_point: if picked_base || several {
|
||||
options.and_then(|o| o.base_point)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
..picked
|
||||
};
|
||||
sweep_model::link_sweep_sources(
|
||||
&mut self.tabs[i].scene.document,
|
||||
created,
|
||||
handle,
|
||||
path_handle,
|
||||
&linked,
|
||||
);
|
||||
}
|
||||
created
|
||||
} else {
|
||||
self.add_solid_model(
|
||||
@@ -549,7 +572,7 @@ impl OpenCADStudio {
|
||||
failed += 1;
|
||||
} else {
|
||||
created_handles.push(created);
|
||||
if delete_profiles {
|
||||
if delete_profiles && !associative {
|
||||
consumed.push(handle);
|
||||
}
|
||||
if deletes_path {
|
||||
|
||||
@@ -933,14 +933,17 @@ mod delobj_tests {
|
||||
expected,
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
sweep_source_presence(2, crate::command::ExtrudeMode::Surface),
|
||||
(false, false),
|
||||
);
|
||||
assert_eq!(
|
||||
sweep_source_presence(3, crate::command::ExtrudeMode::Surface),
|
||||
(false, true),
|
||||
);
|
||||
// A swept surface is associative by default (SURFACEASSOCIATIVITY 1)
|
||||
// and keeps the profile and path it follows, whatever DELOBJ says;
|
||||
// DELOBJ reaches surfaces only with associativity off, a profile-wide
|
||||
// switch this parallel test leaves alone.
|
||||
assert!(crate::scene::model::sweep_model::surface_associativity());
|
||||
for value in [2, 3] {
|
||||
assert_eq!(
|
||||
sweep_source_presence(value, crate::command::ExtrudeMode::Surface),
|
||||
(true, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1128,3 +1131,351 @@ mod dispatched_command_task_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// PW-1 BEDIT characterization tests.
|
||||
///
|
||||
/// Two groups:
|
||||
/// - `locked_*`: behavior Phase 1 must preserve (save keeps edits, discard
|
||||
/// restores the definition, refusals stay refused).
|
||||
/// - `documenting_*`: today's questionable behavior, pinned so the refactor
|
||||
/// cannot change it silently. Each one is expected to flip in Phase 1.
|
||||
#[cfg(test)]
|
||||
mod bedit_characterization_tests {
|
||||
use super::super::*;
|
||||
use codec::entities::{Circle, Insert, Line};
|
||||
use codec::types::Vector3;
|
||||
use codec::EntityType;
|
||||
|
||||
/// Fresh document with one block definition `BLK` (one line) and one
|
||||
/// model-space INSERT referencing it. Returns the INSERT handle and the
|
||||
/// definition's member handles.
|
||||
fn setup_block_and_insert(app: &mut OpenCADStudio) -> (Handle, Vec<Handle>) {
|
||||
let _ = app.automation_op(r#"{"op":"new"}"#);
|
||||
let tab = app.active_tab;
|
||||
app.tabs[tab].scene.define_block_raw(
|
||||
"BLK",
|
||||
Vector3::ZERO,
|
||||
vec![EntityType::Line(Line::from_points(
|
||||
Vector3::ZERO,
|
||||
Vector3::new(10.0, 0.0, 0.0),
|
||||
))],
|
||||
);
|
||||
let insert = app.tabs[tab]
|
||||
.scene
|
||||
.document
|
||||
.add_entity(EntityType::Insert(Insert::new(
|
||||
"BLK",
|
||||
Vector3::new(100.0, 0.0, 0.0),
|
||||
)))
|
||||
.expect("insert");
|
||||
let members = block_members(app, "BLK");
|
||||
(insert, members)
|
||||
}
|
||||
|
||||
fn block_members(app: &OpenCADStudio, name: &str) -> Vec<Handle> {
|
||||
app.tabs[app.active_tab]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get(name)
|
||||
.map(|br| br.entity_handles.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Definition state as (handle, Debug form) in member order. The Debug
|
||||
/// form stands in for PartialEq, which EntityType does not provide.
|
||||
fn definition_state(app: &OpenCADStudio, name: &str) -> Vec<(Handle, String)> {
|
||||
let tab = app.active_tab;
|
||||
block_members(app, name)
|
||||
.into_iter()
|
||||
.map(|h| {
|
||||
let e = app.tabs[tab].scene.document.get_entity(h);
|
||||
(h, format!("{e:?}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn undo_depth(app: &OpenCADStudio) -> usize {
|
||||
app.tabs[app.active_tab].history.undo_stack.len()
|
||||
}
|
||||
|
||||
fn begin_bedit(app: &mut OpenCADStudio, insert: Handle) {
|
||||
let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", insert.value()));
|
||||
assert!(
|
||||
app.tabs[app.active_tab].active_block_edit.is_some(),
|
||||
"setup: BEDIT_BEGIN should open a block-edit session"
|
||||
);
|
||||
}
|
||||
|
||||
fn add_circle_in_bedit(app: &mut OpenCADStudio) -> Handle {
|
||||
let tab = app.active_tab;
|
||||
app.tabs[tab]
|
||||
.scene
|
||||
.add_entity(EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 2.0)))
|
||||
}
|
||||
|
||||
// ---- locked: must survive Phase 1 ----
|
||||
|
||||
/// Locked: save keeps the entity added inside the session.
|
||||
#[test]
|
||||
fn locked_save_keeps_entity_added_in_session() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let (insert, _) = setup_block_and_insert(&mut app);
|
||||
begin_bedit(&mut app, insert);
|
||||
add_circle_in_bedit(&mut app);
|
||||
let _ = app.dispatch_command("BEDIT_SAVE");
|
||||
|
||||
assert!(app.tabs[app.active_tab].active_block_edit.is_none());
|
||||
assert_eq!(
|
||||
block_members(&app, "BLK").len(),
|
||||
2,
|
||||
"save keeps the new entity"
|
||||
);
|
||||
}
|
||||
|
||||
/// Locked: discard restores the definition to its entry state exactly
|
||||
/// (same handles, same geometry, same order).
|
||||
#[test]
|
||||
fn locked_discard_restores_definition_to_entry_state() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let (insert, _) = setup_block_and_insert(&mut app);
|
||||
let entry = definition_state(&app, "BLK");
|
||||
begin_bedit(&mut app, insert);
|
||||
add_circle_in_bedit(&mut app);
|
||||
let _ = app.dispatch_command("BEDIT_DISCARD");
|
||||
|
||||
assert!(app.tabs[app.active_tab].active_block_edit.is_none());
|
||||
assert_eq!(definition_state(&app, "BLK"), entry);
|
||||
}
|
||||
|
||||
/// Locked: refusals. A non-INSERT is rejected and no session opens.
|
||||
#[test]
|
||||
fn locked_bedit_refuses_non_insert_object() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let _ = app.automation_op(r#"{"op":"new"}"#);
|
||||
let tab = app.active_tab;
|
||||
let line = app.tabs[tab]
|
||||
.scene
|
||||
.document
|
||||
.add_entity(EntityType::Line(Line::from_points(
|
||||
Vector3::ZERO,
|
||||
Vector3::new(1.0, 0.0, 0.0),
|
||||
)))
|
||||
.expect("line");
|
||||
let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", line.value()));
|
||||
assert!(app.tabs[tab].active_block_edit.is_none());
|
||||
assert!(app.tabs[tab].block_edits.is_empty());
|
||||
}
|
||||
|
||||
/// Locked: refusal. The `BEDIT` picker command is rejected while a
|
||||
/// REFEDIT session is active and no BEDIT session opens.
|
||||
#[test]
|
||||
fn locked_bedit_picker_refused_while_refedit_active() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let (insert, _) = setup_block_and_insert(&mut app);
|
||||
let tab = app.active_tab;
|
||||
let _ = app.dispatch_command(&format!("REFEDIT_BEGIN:{}", insert.value()));
|
||||
assert!(
|
||||
app.tabs[tab].refedit_session.is_some(),
|
||||
"setup: REFEDIT_BEGIN should open a REFEDIT session"
|
||||
);
|
||||
let _ = app.dispatch_command("BEDIT");
|
||||
assert!(app.tabs[tab].active_block_edit.is_none());
|
||||
assert!(app.tabs[tab].block_edits.is_empty());
|
||||
}
|
||||
|
||||
/// Documenting: the REFEDIT guard lives only in the `BEDIT` picker arm.
|
||||
/// `BEDIT_BEGIN:<handle>` (used by the palette "Edit" action) has no
|
||||
/// REFEDIT check and opens a session while REFEDIT is active. Expected to
|
||||
/// flip in Phase 1 once the guard moves into the begin path.
|
||||
#[test]
|
||||
fn documenting_bedit_begin_bypasses_refedit_guard() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let (insert, _) = setup_block_and_insert(&mut app);
|
||||
let tab = app.active_tab;
|
||||
let _ = app.dispatch_command(&format!("REFEDIT_BEGIN:{}", insert.value()));
|
||||
assert!(app.tabs[tab].refedit_session.is_some());
|
||||
let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", insert.value()));
|
||||
assert!(
|
||||
app.tabs[tab].active_block_edit.is_some(),
|
||||
"documenting: BEDIT_BEGIN opens a session despite an active REFEDIT"
|
||||
);
|
||||
}
|
||||
|
||||
/// Locked: refusal. An xref definition cannot be edited.
|
||||
#[test]
|
||||
fn locked_bedit_refuses_xref_definition() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let (insert, _) = setup_block_and_insert(&mut app);
|
||||
let tab = app.active_tab;
|
||||
app.tabs[tab]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get_mut("BLK")
|
||||
.expect("block record")
|
||||
.flags
|
||||
.is_xref = true;
|
||||
let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", insert.value()));
|
||||
assert!(app.tabs[tab].active_block_edit.is_none());
|
||||
assert!(app.tabs[tab].block_edits.is_empty());
|
||||
}
|
||||
|
||||
// ---- documenting: today's behavior, expected to flip in Phase 1 ----
|
||||
|
||||
/// Documenting: modify an existing member, discard, then undo once. The
|
||||
/// undo target is the BEDIT_BEGIN entry. Records whatever the stack does
|
||||
/// with the stale before-image (ISS-02). The assertion pins the current
|
||||
/// result; Phase 1 must justify any change.
|
||||
#[test]
|
||||
fn documenting_modify_discard_then_undo_probes_stale_before_image() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let (insert, members) = setup_block_and_insert(&mut app);
|
||||
let line = members[0];
|
||||
let entry = definition_state(&app, "BLK");
|
||||
begin_bedit(&mut app, insert);
|
||||
|
||||
let tab = app.active_tab;
|
||||
// Record the undo step before the mutation so the recorder captures it.
|
||||
app.push_undo_snapshot(tab, "modify inside BEDIT");
|
||||
app.tabs[tab]
|
||||
.scene
|
||||
.document
|
||||
.get_entity_mut(line)
|
||||
.map(|e| {
|
||||
if let EntityType::Line(l) = e {
|
||||
l.end = Vector3::new(99.0, 0.0, 0.0);
|
||||
}
|
||||
})
|
||||
.expect("line in definition");
|
||||
app.finish_pending_history(tab);
|
||||
|
||||
let _ = app.dispatch_command("BEDIT_DISCARD");
|
||||
app.finish_pending_history(tab);
|
||||
assert_eq!(
|
||||
definition_state(&app, "BLK"),
|
||||
entry,
|
||||
"discard restores entry"
|
||||
);
|
||||
|
||||
app.undo_steps(1);
|
||||
// Pinned: the state after one undo, observed on current code.
|
||||
assert_eq!(
|
||||
definition_state(&app, "BLK"),
|
||||
entry,
|
||||
"documenting: undo after discard restores the entry state (ISS-02 not reproduced here)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Documenting: undo during a session crosses the entry boundary. A
|
||||
/// single undo after an in-session edit may revert the BEDIT_BEGIN step.
|
||||
#[test]
|
||||
fn documenting_undo_inside_session_crosses_entry_boundary() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let (insert, _) = setup_block_and_insert(&mut app);
|
||||
begin_bedit(&mut app, insert);
|
||||
let tab = app.active_tab;
|
||||
app.push_undo_snapshot(tab, "add circle in BEDIT");
|
||||
add_circle_in_bedit(&mut app);
|
||||
app.finish_pending_history(tab);
|
||||
assert_eq!(block_members(&app, "BLK").len(), 2);
|
||||
|
||||
app.undo_steps(1);
|
||||
// Documenting: undo removes the circle from the entity store but leaves
|
||||
// its handle in the block record, so the definition holds a dangling
|
||||
// member. Expected to flip in Phase 1 (members should match the store).
|
||||
let members = block_members(&app, "BLK");
|
||||
assert_eq!(
|
||||
members.len(),
|
||||
2,
|
||||
"documenting: block record keeps the undone handle"
|
||||
);
|
||||
let dangling = members
|
||||
.iter()
|
||||
.filter(|h| app.tabs[tab].scene.document.get_entity(**h).is_none())
|
||||
.count();
|
||||
assert_eq!(dangling, 1, "documenting: one member handle has no entity");
|
||||
assert!(
|
||||
app.tabs[tab].active_block_edit.is_some(),
|
||||
"documenting: undo does not close the session"
|
||||
);
|
||||
}
|
||||
|
||||
/// Documenting: undoing the BEDIT_BEGIN step itself, while the session is
|
||||
/// still open, leaves the editor open. Records whether the undo reaches
|
||||
/// across the entry boundary.
|
||||
#[test]
|
||||
fn documenting_undo_across_entry_leaves_session_open() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let (insert, _) = setup_block_and_insert(&mut app);
|
||||
let tab = app.active_tab;
|
||||
begin_bedit(&mut app, insert);
|
||||
app.finish_pending_history(tab);
|
||||
let depth_after_begin = undo_depth(&app);
|
||||
assert!(depth_after_begin >= 1, "BEDIT_BEGIN recorded an undo step");
|
||||
|
||||
app.undo_steps(1);
|
||||
assert!(
|
||||
app.tabs[tab].active_block_edit.is_some(),
|
||||
"documenting: undoing BEDIT_BEGIN does not close the open session"
|
||||
);
|
||||
}
|
||||
|
||||
/// Documenting: BEDIT on a block while another block is open nests a
|
||||
/// second session. Records the resulting stack.
|
||||
#[test]
|
||||
fn documenting_nested_bedit_opens_second_session() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let (insert, _) = setup_block_and_insert(&mut app);
|
||||
let tab = app.active_tab;
|
||||
app.tabs[tab].scene.define_block_raw(
|
||||
"OTHER",
|
||||
Vector3::ZERO,
|
||||
vec![EntityType::Line(Line::from_points(
|
||||
Vector3::ZERO,
|
||||
Vector3::new(5.0, 0.0, 0.0),
|
||||
))],
|
||||
);
|
||||
let other_insert = app.tabs[tab]
|
||||
.scene
|
||||
.document
|
||||
.add_entity(EntityType::Insert(Insert::new(
|
||||
"OTHER",
|
||||
Vector3::new(200.0, 0.0, 0.0),
|
||||
)))
|
||||
.expect("insert");
|
||||
begin_bedit(&mut app, insert);
|
||||
begin_bedit(&mut app, other_insert);
|
||||
assert_eq!(
|
||||
app.tabs[tab].block_edits.len(),
|
||||
2,
|
||||
"documenting: a second BEDIT pushes a nested session"
|
||||
);
|
||||
}
|
||||
|
||||
/// Documenting: BEDIT_BEGIN pushes an undo entry, and BEDIT_SAVE and
|
||||
/// BEDIT_DISCARD push none. Phase 1 may change this.
|
||||
#[test]
|
||||
fn documenting_save_and_discard_push_no_undo_entry() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let (insert, _) = setup_block_and_insert(&mut app);
|
||||
begin_bedit(&mut app, insert);
|
||||
app.finish_pending_history(app.active_tab);
|
||||
let after_begin = undo_depth(&app);
|
||||
|
||||
let _ = app.dispatch_command("BEDIT_SAVE");
|
||||
app.finish_pending_history(app.active_tab);
|
||||
assert_eq!(undo_depth(&app), after_begin, "save pushes no undo entry");
|
||||
|
||||
begin_bedit(&mut app, insert);
|
||||
app.finish_pending_history(app.active_tab);
|
||||
let before_discard = undo_depth(&app);
|
||||
let _ = app.dispatch_command("BEDIT_DISCARD");
|
||||
app.finish_pending_history(app.active_tab);
|
||||
assert_eq!(
|
||||
undo_depth(&app),
|
||||
before_discard,
|
||||
"discard pushes no undo entry (not undoable)"
|
||||
);
|
||||
}
|
||||
}
|
||||
+21
-21
@@ -1245,30 +1245,30 @@ impl OpenCADStudio {
|
||||
} else {
|
||||
// EXPLMODE 0 keeps references that are not uniformly and
|
||||
// positively scaled whole.
|
||||
let selected: Vec<_> = if super::blockvars::explmode(
|
||||
&self.tabs[i].scene.document,
|
||||
) == 0
|
||||
{
|
||||
selected
|
||||
.into_iter()
|
||||
.filter(|(_, entity)| match entity {
|
||||
codec::EntityType::Insert(ins) => {
|
||||
let (x, y, z) = (ins.x_scale(), ins.y_scale(), ins.z_scale());
|
||||
let uniform =
|
||||
x > 0.0 && (x - y).abs() <= 1e-10 && (x - z).abs() <= 1e-10;
|
||||
if !uniform {
|
||||
self.command_line.push_output(
|
||||
let selected: Vec<_> =
|
||||
if super::blockvars::explmode(&self.tabs[i].scene.document) == 0 {
|
||||
selected
|
||||
.into_iter()
|
||||
.filter(|(_, entity)| match entity {
|
||||
codec::EntityType::Insert(ins) => {
|
||||
let (x, y, z) =
|
||||
(ins.x_scale(), ins.y_scale(), ins.z_scale());
|
||||
let uniform = x > 0.0
|
||||
&& (x - y).abs() <= 1e-10
|
||||
&& (x - z).abs() <= 1e-10;
|
||||
if !uniform {
|
||||
self.command_line.push_output(
|
||||
"The object is not uniformly and positively scaled.",
|
||||
);
|
||||
}
|
||||
uniform
|
||||
}
|
||||
uniform
|
||||
}
|
||||
_ => true,
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
selected
|
||||
};
|
||||
_ => true,
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
selected
|
||||
};
|
||||
let replacements = plan_explode(&selected, &self.tabs[i].scene.document);
|
||||
let exploded = replacements.len();
|
||||
if exploded > 0 {
|
||||
|
||||
@@ -1145,6 +1145,82 @@ impl OpenCADStudio {
|
||||
for (handle, _) in &selected {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle) {
|
||||
use crate::entities::traits::EntityTypeOps;
|
||||
// A solid's properties come from its B-rep: exact
|
||||
// where the kernel knows the shape, a fine mesh
|
||||
// otherwise — not the display mesh.
|
||||
let acis = match entity {
|
||||
codec::EntityType::Solid3D(solid) => Some(&solid.acis_data),
|
||||
codec::EntityType::Body(body) => Some(&body.acis_data),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(acis) = acis {
|
||||
let properties =
|
||||
crate::scene::convert::solid3d_tess::kernel_acis_body(acis)
|
||||
.and_then(|body| kernel::brep::mass_properties(&body));
|
||||
let Some(p) = properties else {
|
||||
self.command_line.push_error(
|
||||
crate::tf!(
|
||||
"MASSPROP: {} has no closed body to measure.",
|
||||
crate::entities::names::dxf_name(entity),
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let [x, y, z] = p.centroid;
|
||||
let [ix, iy, iz] = p.moment_of_inertia;
|
||||
let [pxy, pyz, pzx] = p.product_of_inertia;
|
||||
let [rx, ry, rz] = p.radii_of_gyration;
|
||||
let [i1, i2, i3] = p.principal_moments;
|
||||
self.command_line.push_output(
|
||||
crate::tf!(
|
||||
"{} Volume={:.4} Centroid=({:.4},{:.4},{:.4})",
|
||||
crate::entities::names::dxf_name(entity),
|
||||
p.volume,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
self.command_line.push_output(
|
||||
crate::tf!(
|
||||
"Moments of inertia X={:.4} Y={:.4} Z={:.4}",
|
||||
ix,
|
||||
iy,
|
||||
iz,
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
self.command_line.push_output(
|
||||
crate::tf!(
|
||||
"Products of inertia XY={:.4} YZ={:.4} ZX={:.4}",
|
||||
pxy,
|
||||
pyz,
|
||||
pzx,
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
self.command_line.push_output(
|
||||
crate::tf!(
|
||||
"Radii of gyration X={:.4} Y={:.4} Z={:.4}",
|
||||
rx,
|
||||
ry,
|
||||
rz,
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
self.command_line.push_output(
|
||||
crate::tf!(
|
||||
"Principal moments about centroid I={:.4} J={:.4} K={:.4}",
|
||||
i1,
|
||||
i2,
|
||||
i3,
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(props) = entity.mass_props() {
|
||||
self.command_line.push_output(
|
||||
crate::tf!(
|
||||
|
||||
@@ -649,6 +649,102 @@ impl OpenCADStudio {
|
||||
.push_info(crate::t!("LAYUNISO: all layers restored.").as_ref());
|
||||
}
|
||||
|
||||
"QLAYER" => {
|
||||
use crate::modules::draw::layers::quick_layer::{
|
||||
QuickLayerCommand, QuickLayerEntry,
|
||||
};
|
||||
|
||||
let layers: Vec<QuickLayerEntry> = self.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|layer| !layer.name.starts_with('*'))
|
||||
.map(|layer| {
|
||||
QuickLayerEntry::new(
|
||||
layer.name.clone(),
|
||||
layer.color.clone(),
|
||||
layer.visible,
|
||||
layer.frozen,
|
||||
layer.locked,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let selected: Vec<codec::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(handle, _)| handle)
|
||||
.collect();
|
||||
|
||||
let command = QuickLayerCommand::new_with_entries(layers, selected);
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
}
|
||||
|
||||
cmd if cmd.starts_with("QLAYER_APPLY_HEX ") => {
|
||||
use crate::modules::draw::layers::quick_layer::QuickLayerCommand;
|
||||
|
||||
let encoded = cmd.trim_start_matches("QLAYER_APPLY_HEX").trim();
|
||||
|
||||
let Some(requested) = QuickLayerCommand::decode_layer_name(encoded) else {
|
||||
self.command_line
|
||||
.push_error(crate::t!("QLAYER: invalid encoded layer name.").as_ref());
|
||||
return Some(Task::none());
|
||||
};
|
||||
|
||||
let resolved = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.names()
|
||||
.find(|name| name.eq_ignore_ascii_case(&requested))
|
||||
.map(|name| name.to_string());
|
||||
|
||||
let Some(layer) = resolved else {
|
||||
self.command_line
|
||||
.push_error(crate::tf!("QLAYER: no layer named \"{requested}\".").as_ref());
|
||||
return Some(Task::none());
|
||||
};
|
||||
|
||||
let handles: Vec<codec::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(handle, _)| handle)
|
||||
.collect();
|
||||
|
||||
if handles.is_empty() {
|
||||
match self.set_current_layer_name(i, &layer) {
|
||||
Ok(()) => {
|
||||
self.command_line.push_info(
|
||||
crate::tf!("QLAYER: current layer set to \"{layer}\".").as_ref(),
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
self.command_line.push_error(&error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let count = handles.len();
|
||||
|
||||
self.apply_property_op(i, "QLAYER", &handles, |app, handle| {
|
||||
if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
crate::scene::view::dispatch::apply_common_prop(
|
||||
entity, "layer", &layer,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.ribbon.active_layer = layer.clone();
|
||||
|
||||
self.command_line.push_info(
|
||||
crate::tf!("QLAYER: moved {count} object(s) to layer \"{layer}\".")
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
"LAYMATCH" | "LAYMCH" => {
|
||||
use crate::modules::draw::layers::match_layer::LayMatchCommand;
|
||||
let dest: Vec<_> = self.tabs[i]
|
||||
|
||||
@@ -706,6 +706,7 @@ inventory::submit!(crate::command::CommandRegistration {
|
||||
"PDFOSNAP",
|
||||
"UOSNAP",
|
||||
"FIELDDISPLAY",
|
||||
"SURFACEASSOCIATIVITY",
|
||||
"FIELDEVAL",
|
||||
"PDFIMPORTMODE",
|
||||
"PDFIMPORTFILTER",
|
||||
|
||||
@@ -293,7 +293,7 @@ impl OpenCADStudio {
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(name.clone())
|
||||
Some(name.to_uppercase())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -320,6 +320,17 @@ impl OpenCADStudio {
|
||||
.map(|ds| ds.dimtxsty.clone()),
|
||||
)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_uppercase())
|
||||
.collect();
|
||||
// Complex linetypes reach their text and shape styles by handle.
|
||||
let linetype_styles: rustc_hash::FxHashSet<codec::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.line_types
|
||||
.iter()
|
||||
.flat_map(|lt| lt.elements.iter())
|
||||
.filter_map(|element| element.complex.as_ref())
|
||||
.map(|complex| complex.style_handle)
|
||||
.collect();
|
||||
// Linetypes: entity references plus every layer's own
|
||||
// linetype — a ByLayer entity reaches it through the layer.
|
||||
@@ -344,6 +355,7 @@ impl OpenCADStudio {
|
||||
.map(|l| l.line_type.clone()),
|
||||
)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_uppercase())
|
||||
.collect();
|
||||
|
||||
// Live blocks are reachable from layouts and style roots
|
||||
@@ -409,7 +421,9 @@ impl OpenCADStudio {
|
||||
.document
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| l.name != "0" && !used_layers.contains(&l.name))
|
||||
.filter(|l| {
|
||||
l.name != "0" && !used_layers.contains(&l.name.to_uppercase())
|
||||
})
|
||||
.map(|l| l.name.clone())
|
||||
.collect()
|
||||
} else {
|
||||
@@ -421,7 +435,15 @@ impl OpenCADStudio {
|
||||
.document
|
||||
.text_styles
|
||||
.iter()
|
||||
.filter(|s| s.name != "Standard" && !used_text_styles.contains(&s.name))
|
||||
// Unnamed shape-file records serve complex linetypes
|
||||
// and are never listed for purging.
|
||||
.filter(|s| {
|
||||
!s.name.is_empty()
|
||||
&& !s.is_shape_file
|
||||
&& !s.name.eq_ignore_ascii_case("Standard")
|
||||
&& !used_text_styles.contains(&s.name.to_uppercase())
|
||||
&& !linetype_styles.contains(&s.handle)
|
||||
})
|
||||
.map(|s| s.name.clone())
|
||||
.collect()
|
||||
} else {
|
||||
@@ -436,7 +458,7 @@ impl OpenCADStudio {
|
||||
.iter()
|
||||
.filter(|lt| {
|
||||
!standard.iter().any(|s| s.eq_ignore_ascii_case(<.name))
|
||||
&& !used_linetypes.contains(<.name)
|
||||
&& !used_linetypes.contains(<.name.to_uppercase())
|
||||
})
|
||||
.map(|lt| lt.name.clone())
|
||||
.collect()
|
||||
@@ -483,16 +505,23 @@ impl OpenCADStudio {
|
||||
snapshot_pushed = true;
|
||||
}
|
||||
|
||||
// Apply removals (mutable)
|
||||
for name in &layer_remove {
|
||||
self.tabs[i].scene.document.layers.remove(name);
|
||||
}
|
||||
for name in &style_remove {
|
||||
self.tabs[i].scene.document.text_styles.remove(name);
|
||||
}
|
||||
for name in <_remove {
|
||||
self.tabs[i].scene.document.line_types.remove(name);
|
||||
}
|
||||
// Apply removals (mutable). Count what actually went: an
|
||||
// entry the table cannot remove by name would otherwise be
|
||||
// listed again on every pass and never let the loop end.
|
||||
let doc = &mut self.tabs[i].scene.document;
|
||||
let layers_gone = layer_remove
|
||||
.iter()
|
||||
.filter(|n| doc.layers.remove(n).is_some())
|
||||
.count();
|
||||
let styles_gone = style_remove
|
||||
.iter()
|
||||
.filter(|n| doc.text_styles.remove(n).is_some())
|
||||
.count();
|
||||
let lts_gone = lt_remove
|
||||
.iter()
|
||||
.filter(|n| doc.line_types.remove(n).is_some())
|
||||
.count();
|
||||
let mut blocks_gone = 0usize;
|
||||
for name in &block_remove {
|
||||
// Drop the block definition's member entities (and the
|
||||
// BLOCK/ENDBLK delimiters) before the record so no orphaned
|
||||
@@ -509,12 +538,23 @@ impl OpenCADStudio {
|
||||
for h in handles {
|
||||
self.tabs[i].scene.document.remove_entity(h);
|
||||
}
|
||||
self.tabs[i].scene.document.block_records.remove(name);
|
||||
if self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.remove(name)
|
||||
.is_some()
|
||||
{
|
||||
blocks_gone += 1;
|
||||
}
|
||||
}
|
||||
n_layers += layers_gone;
|
||||
n_styles += styles_gone;
|
||||
n_lts += lts_gone;
|
||||
n_blocks += blocks_gone;
|
||||
if layers_gone + styles_gone + lts_gone + blocks_gone == 0 {
|
||||
break;
|
||||
}
|
||||
n_layers += layer_remove.len();
|
||||
n_styles += style_remove.len();
|
||||
n_lts += lt_remove.len();
|
||||
n_blocks += block_remove.len();
|
||||
}
|
||||
|
||||
// Remove draw-order tables whose owning blocks are gone.
|
||||
@@ -1246,6 +1286,7 @@ impl OpenCADStudio {
|
||||
| "DGNOSNAP"
|
||||
| "UOSNAP"
|
||||
| "FIELDDISPLAY"
|
||||
| "SURFACEASSOCIATIVITY"
|
||||
| "PDFIMPORTMODE"
|
||||
| "PDFIMPORTFILTER"
|
||||
| "PDFIMPORTLAYERS"
|
||||
@@ -1484,6 +1525,36 @@ impl OpenCADStudio {
|
||||
}
|
||||
return Some(self.finish_dispatch(cmd));
|
||||
}
|
||||
if name == "SURFACEASSOCIATIVITY" {
|
||||
use crate::scene::model::sweep_model;
|
||||
let current = i16::from(sweep_model::surface_associativity());
|
||||
match value
|
||||
.as_deref()
|
||||
.map(|v| v.trim().parse::<i16>().ok().filter(|v| (0..=1).contains(v)))
|
||||
{
|
||||
Some(Some(mode)) => {
|
||||
if current != mode {
|
||||
sweep_model::set_surface_associativity(mode == 1);
|
||||
self.save_config();
|
||||
}
|
||||
}
|
||||
Some(None) => {
|
||||
self.command_line
|
||||
.push_error(crate::t!("Requires 0 or 1 only.").as_ref());
|
||||
self.command_line.push_output(&format!(
|
||||
"Enter new value for SURFACEASSOCIATIVITY <{current}>:"
|
||||
));
|
||||
self.pending_setvar = Some(name.clone());
|
||||
}
|
||||
None => {
|
||||
self.command_line.push_output(&format!(
|
||||
"Enter new value for SURFACEASSOCIATIVITY <{current}>:"
|
||||
));
|
||||
self.pending_setvar = Some(name.clone());
|
||||
}
|
||||
}
|
||||
return Some(self.finish_dispatch(cmd));
|
||||
}
|
||||
if name == "FIELDDISPLAY" {
|
||||
let current = i16::from(crate::entities::field::display());
|
||||
match value
|
||||
@@ -3889,6 +3960,13 @@ fn rename_symbol(doc: &mut codec::CadDocument, ty: &str, old: &str, new: &str) -
|
||||
if doc.header.current_text_style_name.eq_ignore_ascii_case(old) {
|
||||
doc.header.current_text_style_name = new.to_string();
|
||||
}
|
||||
// Dimension styles name their text style; follow the rename (same
|
||||
// as rename_style_storage — see #42).
|
||||
for ds in doc.dim_styles.iter_mut() {
|
||||
if ds.dimtxsty.eq_ignore_ascii_case(old) {
|
||||
ds.dimtxsty = new.to_string();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
"DIMSTYLE" => {
|
||||
|
||||
@@ -814,7 +814,7 @@ impl OpenCADStudio {
|
||||
vp.view_height,
|
||||
vp.height,
|
||||
),
|
||||
vp.status.is_on,
|
||||
vp.is_on(),
|
||||
vp.status.locked,
|
||||
))
|
||||
} else {
|
||||
|
||||
@@ -206,6 +206,12 @@ pub(super) struct DocumentTab {
|
||||
pub(super) dyn_active: usize,
|
||||
pub(super) history: HistoryState,
|
||||
pub(super) active_layer: String,
|
||||
/// Block references whose properties panel shows per-axis Scale X/Y/Z even
|
||||
/// though the three factors are currently equal — the user unchecked the
|
||||
/// "Uniform scale" box for them (#427). Per-tab: fresh documents restart
|
||||
/// handle numbering, so an app-global set keyed by raw handle leaks one
|
||||
/// tab's opt-out into another tab reusing the value (#47).
|
||||
pub(super) props_asym_scale: std::collections::HashSet<u64>,
|
||||
/// Node graph shown by the command line's graph button.
|
||||
pub(super) graph: crate::ui::node_graph::Graph,
|
||||
/// Currently active UCS. `None` means WCS (identity transform).
|
||||
@@ -648,6 +654,7 @@ impl DocumentTab {
|
||||
dyn_active: 0,
|
||||
history: HistoryState::default(),
|
||||
active_layer: "0".to_string(),
|
||||
props_asym_scale: std::collections::HashSet::new(),
|
||||
graph: Default::default(),
|
||||
active_ucs: None,
|
||||
sketch_session: None,
|
||||
|
||||
+20
-1
@@ -62,10 +62,29 @@ impl OpenCADStudio {
|
||||
let i = self.active_tab;
|
||||
let doc_layers = self.tabs[i].scene.document.layers.clone();
|
||||
let vp_info = self.tabs[i].scene.viewport_list();
|
||||
let names_before: Vec<String> = self.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| l.name.clone())
|
||||
.collect();
|
||||
self.tabs[i]
|
||||
.layers
|
||||
.sync_with_viewports(&doc_layers, vp_info);
|
||||
self.sync_ribbon_layers();
|
||||
// The Properties palette lists layer names it read when it was built;
|
||||
// a layer added, removed or renamed must reach that list too (#1664).
|
||||
// Only then: the rebuild is costly for a large selection.
|
||||
let names_changed = self.tabs[i].layers.layers.len() != names_before.len()
|
||||
|| self.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter()
|
||||
.zip(&names_before)
|
||||
.any(|(layer, before)| layer.name != *before);
|
||||
if names_changed {
|
||||
self.refresh_properties();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick up layers an entity edit registered on the fly (`ensure_layer`).
|
||||
@@ -207,7 +226,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let active_table = self.ribbon.active_table_style.clone();
|
||||
let active_table = doc.header.current_table_style_name.clone();
|
||||
let active_table = if table_names.contains(&active_table) {
|
||||
active_table
|
||||
} else {
|
||||
|
||||
+38
-17
@@ -9,7 +9,7 @@ pub(crate) mod config;
|
||||
pub use automation::{export_headless, serve};
|
||||
mod annotation_data;
|
||||
mod command_driver;
|
||||
pub(crate) use command_driver::{copy_to_clipboard_kernel, paste_entities_kernel};
|
||||
pub(crate) use command_driver::{copy_to_clipboard_kernel, paste_entities_kernel, PasteSpace};
|
||||
pub(crate) mod commands;
|
||||
pub(crate) mod dim_viewport;
|
||||
#[cfg(test)]
|
||||
@@ -420,10 +420,6 @@ pub(super) struct OpenCADStudio {
|
||||
discussions: Vec<crate::discussions::DiscussionEntry>,
|
||||
/// True while the boot-time Discussions refresh is still in flight.
|
||||
discussions_loading: bool,
|
||||
/// Block references whose properties panel shows per-axis Scale X/Y/Z even
|
||||
/// though the three factors are currently equal — the user unchecked the
|
||||
/// "Uniform scale" box for them (#427). Keyed by entity handle.
|
||||
props_asym_scale: std::collections::HashSet<u64>,
|
||||
/// Collapsed Properties-panel section titles. This belongs to the app,
|
||||
/// rather than an individual document tab, so the same view preference is
|
||||
/// used by every currently open drawing/project.
|
||||
@@ -560,6 +556,10 @@ pub(super) struct OpenCADStudio {
|
||||
polar_increment_deg: f32,
|
||||
/// Reverse the mouse-wheel zoom direction when true (ZOOMWHEEL = 1).
|
||||
zoom_wheel_reversed: bool,
|
||||
/// Scroll pans and Ctrl+scroll zooms (touchpads); see `scroll_intent`.
|
||||
touchpad_pan: bool,
|
||||
/// Interface scale in percent, applied through the window scale factor.
|
||||
ui_scale_percent: u16,
|
||||
/// Mouse-wheel zoom sensitivity (ZOOMFACTOR), held inside
|
||||
/// `settings::ZOOM_FACTOR_MIN..=settings::ZOOM_FACTOR_MAX`. `SETVAR`
|
||||
/// only sets the range the system variable itself has; the Options
|
||||
@@ -1121,6 +1121,14 @@ pub(super) struct OpenCADStudio {
|
||||
/// Snapshot of the dialog's settings taken when it opened, restored by the
|
||||
/// `<previous>` list entry.
|
||||
plot_prev: Option<crate::ui::window::plot::PlotDialogState>,
|
||||
/// Tab/layout that last owned the Plot dialog. Reopening Plot in the same
|
||||
/// context keeps the user's last paper, scale, CTB and output choices
|
||||
/// instead of reapplying the layout page setup over them.
|
||||
plot_prev_context: Option<(usize, String)>,
|
||||
/// Printer whose capability reply is allowed to reconcile the current
|
||||
/// sheet. Set only after an explicit printer/page-setup choice; merely
|
||||
/// opening Plot must never replace the drawing's stored paper size.
|
||||
plot_reconcile_printer_media: Option<String>,
|
||||
/// Full source settings behind the fields currently shown in Plot.
|
||||
plot_setup_template: Option<codec::objects::PlotSettings>,
|
||||
/// Paper layouts shown by Print All, in tab order with their selection.
|
||||
@@ -2386,6 +2394,9 @@ pub enum Message {
|
||||
CommandLineFadeChanged(i32),
|
||||
/// Toggle reversing the mouse-wheel zoom direction (ZOOMWHEEL).
|
||||
ZoomWheelReversedChanged(bool),
|
||||
TouchpadPanChanged(bool),
|
||||
/// Interface scale in percent (Options > Display).
|
||||
UiScaleChanged(u16),
|
||||
/// Change how far one wheel notch zooms, from the Options slider
|
||||
/// (ZOOMFACTOR).
|
||||
ZoomFactorChanged(i32),
|
||||
@@ -2596,8 +2607,11 @@ pub enum Message {
|
||||
/// Pop the trailing character off the command-line input — backspace
|
||||
/// counterpart to `CommandAppendChar`.
|
||||
CommandBackspace,
|
||||
/// TAB pressed: move focus to the next dynamic-input field (wraps).
|
||||
/// TAB pressed: move focus to the next dynamic-input field or
|
||||
/// autocomplete suggestion.
|
||||
DynTabNext,
|
||||
/// Shift+TAB: move to the previous contextual autocomplete suggestion.
|
||||
DynTabPrev,
|
||||
/// Split the active Model viewport in two. `true` → horizontal divider
|
||||
/// (top / bottom); `false` → vertical divider (left / right).
|
||||
SplitModelViewport(bool),
|
||||
@@ -2668,11 +2682,11 @@ pub enum Message {
|
||||
ToggleXrefManager,
|
||||
/// Rescan the active drawing's references into the palette.
|
||||
XrefManagerRefresh,
|
||||
/// Toggle one palette row (entry index) in the multi-selection set.
|
||||
XrefManagerSelect(usize),
|
||||
/// Toggle one palette row (stable row id) in the multi-selection set.
|
||||
XrefManagerSelect(crate::ui::window::xref_manager::XrefRowId),
|
||||
/// Right-click on a palette row: single-select it when outside the
|
||||
/// selection (the context menu then acts on the selection).
|
||||
XrefRowRightClick(usize),
|
||||
XrefRowRightClick(crate::ui::window::xref_manager::XrefRowId),
|
||||
/// Flip the palette's list/tree presentation.
|
||||
XrefManagerToggleTree,
|
||||
/// Start a reference-table column divider drag (column index).
|
||||
@@ -2709,16 +2723,19 @@ pub enum Message {
|
||||
XrefManagerToggleExpand(u64),
|
||||
/// Selection-scoped palette operation (detach/unload/reload/overlay/pathtype).
|
||||
XrefManagerOp(crate::ui::window::xref_manager::XrefPaletteOp),
|
||||
/// Row-scoped palette operation: selects row `index`, then applies the
|
||||
/// operation to it.
|
||||
XrefRowOp(usize, crate::ui::window::xref_manager::XrefPaletteOp),
|
||||
/// Row-scoped palette operation: selects the row by stable id, then
|
||||
/// applies the operation to it.
|
||||
XrefRowOp(
|
||||
crate::ui::window::xref_manager::XrefRowId,
|
||||
crate::ui::window::xref_manager::XrefPaletteOp,
|
||||
),
|
||||
/// Prefill the command line for Find & Replace across references
|
||||
/// (`XREF Path Find <old> <new>`); the CLI parses and runs it.
|
||||
XrefFindReplacePrompt,
|
||||
/// Pick a new path for a specific row index.
|
||||
XrefRowPathPick(usize),
|
||||
/// Prefill the command line for Find & Replace for a specific row index.
|
||||
XrefRowFindReplacePrompt(usize),
|
||||
/// Pick a new path for a specific row (stable id).
|
||||
XrefRowPathPick(crate::ui::window::xref_manager::XrefRowId),
|
||||
/// Prefill the command line for Find & Replace for a specific row id.
|
||||
XrefRowFindReplacePrompt(crate::ui::window::xref_manager::XrefRowId),
|
||||
XrefRowChangePathEnter,
|
||||
XrefRowChangePathLeave,
|
||||
LayerToggleVisible(String),
|
||||
@@ -4103,7 +4120,6 @@ impl OpenCADStudio {
|
||||
videos_loading: false,
|
||||
discussions: Vec::new(),
|
||||
discussions_loading: false,
|
||||
props_asym_scale: std::collections::HashSet::new(),
|
||||
collapsed_property_sections: rustc_hash::FxHashSet::default(),
|
||||
start_section: StartSection::default(),
|
||||
start_action_w: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)),
|
||||
@@ -4168,6 +4184,8 @@ impl OpenCADStudio {
|
||||
polar_mode: false,
|
||||
polar_increment_deg: 45.0,
|
||||
zoom_wheel_reversed: false,
|
||||
touchpad_pan: false,
|
||||
ui_scale_percent: 100,
|
||||
zoom_factor: 60,
|
||||
zoom_factor_input: 60.to_string(),
|
||||
cursor_size: 5,
|
||||
@@ -4409,6 +4427,8 @@ impl OpenCADStudio {
|
||||
plot_orientation: crate::io::paper_catalog::Orientation::Landscape,
|
||||
plot_dialog: crate::ui::window::plot::PlotDialogState::default(),
|
||||
plot_prev: None,
|
||||
plot_prev_context: None,
|
||||
plot_reconcile_printer_media: None,
|
||||
plot_setup_template: None,
|
||||
print_all_layouts: Vec::new(),
|
||||
print_all_options: false,
|
||||
@@ -4918,6 +4938,7 @@ pub fn run() -> iced::Result {
|
||||
..iced::Settings::default()
|
||||
})
|
||||
.subscription(OpenCADStudio::subscription)
|
||||
.scale_factor(|state: &OpenCADStudio, _| f32::from(state.ui_scale_percent) / 100.0)
|
||||
.title(|state: &OpenCADStudio, window_id: window::Id| {
|
||||
let _ = window_id; // all dialogs are in-canvas modals now
|
||||
if let Some(tab) = state.tabs.get(state.active_tab) {
|
||||
|
||||
+13
-12
@@ -2570,14 +2570,7 @@ impl<'a> HostSession<'a> {
|
||||
}
|
||||
self.push_undo("Rename style");
|
||||
self.app.rename_style_storage(kind_of(kind), &from, &to);
|
||||
if kind == TableStyleKind::Text {
|
||||
// A dimension style names its text style; follow the rename.
|
||||
for dim in self.document_mut().dim_styles.iter_mut() {
|
||||
if dim.dimtxsty.eq_ignore_ascii_case(&from) {
|
||||
dim.dimtxsty = to.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
// dimtxsty follow lives in rename_style_storage (#42).
|
||||
self.finish_style_change(kind_of(kind));
|
||||
Ok(handle)
|
||||
}
|
||||
@@ -13043,7 +13036,11 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0)))
|
||||
|
||||
// Verify COGO_LINES does not exist initially
|
||||
assert!(!app.tabs[0].scene.document.layers.contains("COGO_LINES"));
|
||||
assert!(!app.ribbon.layer_names.contains(&"COGO_LINES".to_string()));
|
||||
assert!(!app
|
||||
.ribbon
|
||||
.layer_infos
|
||||
.iter()
|
||||
.any(|l| l.name == "COGO_LINES"));
|
||||
|
||||
// 1. Add entity via HostSession (simulating plugin add_entity / add_line)
|
||||
{
|
||||
@@ -13057,7 +13054,10 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0)))
|
||||
// Verify layer is created in document AND refreshed in ribbon + layer panel
|
||||
assert!(app.tabs[0].scene.document.layers.contains("COGO_LINES"));
|
||||
assert!(
|
||||
app.ribbon.layer_names.contains(&"COGO_LINES".to_string()),
|
||||
app.ribbon
|
||||
.layer_infos
|
||||
.iter()
|
||||
.any(|l| l.name == "COGO_LINES"),
|
||||
"Ribbon layers should contain COGO_LINES after add_entity"
|
||||
);
|
||||
assert!(
|
||||
@@ -13082,8 +13082,9 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0)))
|
||||
.contains("SURVEY_TRAVERSE"));
|
||||
assert!(
|
||||
app.ribbon
|
||||
.layer_names
|
||||
.contains(&"SURVEY_TRAVERSE".to_string()),
|
||||
.layer_infos
|
||||
.iter()
|
||||
.any(|l| l.name == "SURVEY_TRAVERSE"),
|
||||
"Ribbon layers should contain SURVEY_TRAVERSE after commit_entity"
|
||||
);
|
||||
assert!(
|
||||
|
||||
+39
-1
@@ -439,7 +439,7 @@ impl OpenCADStudio {
|
||||
if let codec::EntityType::Insert(ins) = entity {
|
||||
let eq = (ins.x_scale() - ins.y_scale()).abs() < 1e-12
|
||||
&& (ins.x_scale() - ins.z_scale()).abs() < 1e-12;
|
||||
let uniform = eq && !self.props_asym_scale.contains(&handle.value());
|
||||
let uniform = eq && !self.tabs[i].props_asym_scale.contains(&handle.value());
|
||||
for section in sections.iter_mut() {
|
||||
let Some(xi) = section.props.iter().position(|p| p.field == "x_scale") else {
|
||||
continue;
|
||||
@@ -4223,6 +4223,44 @@ fn apply_insert_unit_scale(ins: &mut codec::entities::Insert, ratio: f64) -> boo
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod asym_scale_tests {
|
||||
// #47: the uniform-scale opt-out must be per-tab. Fresh documents restart
|
||||
// handle numbering (seed 1), so two tabs hand out identical handles — an
|
||||
// app-global set leaks one tab's opt-out into the other.
|
||||
use crate::app::OpenCADStudio;
|
||||
|
||||
fn tab_with_insert(app: &mut OpenCADStudio) -> (usize, codec::Handle) {
|
||||
app.tab_counter += 1;
|
||||
let n = app.tab_counter;
|
||||
app.tabs
|
||||
.push(crate::app::document::DocumentTab::new_drawing(n));
|
||||
let idx = app.tabs.len() - 1;
|
||||
app.active_tab = idx;
|
||||
let ins = codec::entities::Insert::new("B", codec::types::Vector3::ZERO);
|
||||
let h = app
|
||||
.commit_entity_handle(codec::EntityType::Insert(ins))
|
||||
.expect("insert commits");
|
||||
(idx, h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asym_scale_opt_out_does_not_leak_across_tabs() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let (a, ha) = tab_with_insert(&mut app);
|
||||
let (b, hb) = tab_with_insert(&mut app);
|
||||
assert_eq!(
|
||||
ha, hb,
|
||||
"premise: identically built fresh docs reuse handle values"
|
||||
);
|
||||
app.tabs[a].props_asym_scale.insert(ha.value());
|
||||
assert!(
|
||||
!app.tabs[b].props_asym_scale.contains(&hb.value()),
|
||||
"tab B must not see tab A's opt-out"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod insert_unit_scale_tests {
|
||||
use super::{apply_insert_unit_scale, format_unit_factor, insert_unit_scale, insunits_to_mm};
|
||||
|
||||
@@ -178,6 +178,11 @@ pub const ZOOM_FACTOR_MAX: i32 = 500;
|
||||
/// Hold a ZOOMFACTOR inside the range the application can store, wherever it
|
||||
/// arrives from — the slider, the Options field, or a configuration file
|
||||
/// written by hand or by an older build.
|
||||
/// Interface scale in percent, kept to the range the Options slider offers.
|
||||
pub fn clamp_ui_scale(percent: u16) -> u16 {
|
||||
percent.clamp(75, 200)
|
||||
}
|
||||
|
||||
pub fn clamp_zoom_factor(v: i32) -> i32 {
|
||||
v.clamp(ZOOM_FACTOR_MIN, ZOOM_FACTOR_MAX)
|
||||
}
|
||||
@@ -383,6 +388,11 @@ pub struct UserSettings {
|
||||
pub polar: bool,
|
||||
pub polar_increment_deg: f32,
|
||||
pub zoom_wheel_reversed: bool,
|
||||
/// Scrolling pans and Ctrl+scroll (a touchpad pinch) zooms, for a
|
||||
/// touchpad that reports two-finger drags as wheel scrolls.
|
||||
pub touchpad_pan: bool,
|
||||
/// Interface scale in percent, on top of the display's own scale.
|
||||
pub ui_scale_percent: u16,
|
||||
pub zoom_factor: i32,
|
||||
/// CURSORSIZE: normalized crosshair reach; 5 retains the original 60 px arms.
|
||||
pub cursor_size: i32,
|
||||
@@ -407,6 +417,8 @@ pub struct UserSettings {
|
||||
pub attdef_on_screen: bool,
|
||||
/// FIELDDISPLAY: fields show on a gray background.
|
||||
pub field_display: bool,
|
||||
/// SURFACEASSOCIATIVITY: new surfaces stay associative to their sources.
|
||||
pub surface_associativity: bool,
|
||||
/// Nested-copy symbol handling: false inserts, true binds.
|
||||
pub ncopy_bind: bool,
|
||||
/// Last Options page; unknown saved names fall back without rejecting the config.
|
||||
@@ -805,6 +817,8 @@ impl Default for UserSettings {
|
||||
polar: false,
|
||||
polar_increment_deg: 45.0,
|
||||
zoom_wheel_reversed: false,
|
||||
touchpad_pan: false,
|
||||
ui_scale_percent: 100,
|
||||
zoom_factor: 60,
|
||||
cursor_size: 5,
|
||||
pick_box: 3,
|
||||
@@ -821,6 +835,7 @@ impl Default for UserSettings {
|
||||
image_frame: 1,
|
||||
attdef_on_screen: true,
|
||||
field_display: true,
|
||||
surface_associativity: true,
|
||||
ncopy_bind: false,
|
||||
cursor_type: CursorType::Crosshair,
|
||||
crosshair_color: None,
|
||||
|
||||
@@ -50,6 +50,7 @@ pub(super) fn default_bindings() -> BTreeMap<String, String> {
|
||||
("DELETE".to_string(), "DELETESELECTED"),
|
||||
("BACKSPACE".to_string(), "BACKSPACE"),
|
||||
("TAB".to_string(), "DYNTAB"),
|
||||
("SHIFT+TAB".to_string(), "DYNTABPREV"),
|
||||
("UP".to_string(), "HISTORYPREV"),
|
||||
("DOWN".to_string(), "HISTORYNEXT"),
|
||||
("LEFT".to_string(), "CARETLEFT"),
|
||||
@@ -154,6 +155,7 @@ pub(super) const INPUT_ACTIONS: &[&str] = &[
|
||||
"DELETESELECTED",
|
||||
"BACKSPACE",
|
||||
"DYNTAB",
|
||||
"DYNTABPREV",
|
||||
"HISTORYPREV",
|
||||
"HISTORYNEXT",
|
||||
"CARETLEFT",
|
||||
@@ -310,6 +312,7 @@ impl OpenCADStudio {
|
||||
"DELETESELECTED" => Message::DeleteSelected,
|
||||
"BACKSPACE" => Message::CommandBackspace,
|
||||
"DYNTAB" => Message::DynTabNext,
|
||||
"DYNTABPREV" => Message::DynTabPrev,
|
||||
"HISTORYPREV" => Message::CommandHistoryPrev,
|
||||
"HISTORYNEXT" => Message::CommandHistoryNext,
|
||||
"CARETLEFT" => Message::MTextCaretMove(-1),
|
||||
|
||||
+37
-1
@@ -291,7 +291,8 @@ impl OpenCADStudio {
|
||||
StyleKind::Text => self.load_textstyle_bufs(i),
|
||||
StyleKind::Dim => self.load_dimstyle_bufs(i),
|
||||
StyleKind::MLeader => self.load_mleaderstyle_bufs(i),
|
||||
StyleKind::Table | StyleKind::MLine => {}
|
||||
StyleKind::Table => self.load_tablestyle_bufs(i),
|
||||
StyleKind::MLine => self.load_mlstyle_bufs(i),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,6 +598,13 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dimension styles name their text style; follow the rename so
|
||||
// Apply keeps resolving the handle instead of collapsing to NULL.
|
||||
for ds in doc.dim_styles.iter_mut() {
|
||||
if ds.dimtxsty.eq_ignore_ascii_case(old) {
|
||||
ds.dimtxsty = new.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
StyleKind::Dim => {
|
||||
let doc = &mut self.tabs[i].scene.document;
|
||||
@@ -1244,3 +1252,31 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rename_follow_tests {
|
||||
// #42: renaming a text style must follow into dimstyle dimtxsty.
|
||||
use super::{OpenCADStudio, StyleKind};
|
||||
use codec::tables::{DimStyle, TextStyle};
|
||||
|
||||
#[test]
|
||||
fn text_rename_follows_dimtxsty() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let i = app.active_tab;
|
||||
let mut ts = TextStyle::new("OLD");
|
||||
ts.handle = app.tabs[i].scene.document.allocate_handle();
|
||||
app.tabs[i].scene.document.text_styles.add(ts).unwrap();
|
||||
let mut ds = DimStyle::new("D");
|
||||
ds.dimtxsty = "OLD".to_string();
|
||||
app.tabs[i].scene.document.dim_styles.add(ds).unwrap();
|
||||
app.style_rename = Some("OLD".to_string());
|
||||
app.style_rename_buf = "NEW".to_string();
|
||||
app.style_rename_commit(StyleKind::Text);
|
||||
let dim = app.tabs[i].scene.document.dim_styles.get("D").unwrap();
|
||||
assert_eq!(
|
||||
dim.dimtxsty, "NEW",
|
||||
"dimstyle must follow the text-style rename"
|
||||
);
|
||||
}
|
||||
}
|
||||
+361
-34
@@ -304,16 +304,21 @@ impl OpenCADStudio {
|
||||
// Live incremental search for INSERT/MINSERT (see CommandInput)
|
||||
let live = self.command_line.input.clone();
|
||||
let i = self.active_tab;
|
||||
let (should_update, opts, prompt) =
|
||||
let (should_update, opts, prompt, suggestions) =
|
||||
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||||
if cmd.on_live_input(&live) {
|
||||
(true, cmd.options(), cmd.prompt())
|
||||
let changed = cmd.on_live_input(&live);
|
||||
let suggestions = cmd.text_suggestions(&live);
|
||||
if changed {
|
||||
(true, cmd.options(), cmd.prompt(), suggestions)
|
||||
} else {
|
||||
(false, Vec::new(), String::new())
|
||||
(false, Vec::new(), String::new(), suggestions)
|
||||
}
|
||||
} else {
|
||||
(false, Vec::new(), String::new())
|
||||
(false, Vec::new(), String::new(), Vec::new())
|
||||
};
|
||||
|
||||
self.command_line.set_contextual_suggestions(suggestions);
|
||||
|
||||
if should_update {
|
||||
self.command_line.set_step_options(opts);
|
||||
if let Some(last) = self.command_line.history.last_mut() {
|
||||
@@ -750,6 +755,12 @@ impl OpenCADStudio {
|
||||
self.command_line.autocomplete_cursor = None;
|
||||
return self.dispatch_command(&command);
|
||||
}
|
||||
} else if self.command_line.has_contextual_suggestions() {
|
||||
// During an active command Enter accepts the highlighted
|
||||
// contextual value instead of the user's partial search.
|
||||
if let Some(value) = self.command_line.selected_suggestion() {
|
||||
self.command_line.input = value;
|
||||
}
|
||||
}
|
||||
let i = self.active_tab;
|
||||
// A whole multi-token command line (`UCS Z 90`, `LINE 0,0
|
||||
@@ -1003,7 +1014,8 @@ impl OpenCADStudio {
|
||||
// A tracking ray carries its own base, so it needs no anchor: that is
|
||||
// how OTRACK and Extension accepted a bare distance for the *first*
|
||||
// point of a command, before any anchor exists.
|
||||
let pt = if let Some((base, dir)) = self.active_distance_ray(i) {
|
||||
let distance_ray = self.active_distance_ray(i);
|
||||
let pt = if let Some((base, dir)) = distance_ray {
|
||||
base + dir * dist
|
||||
} else {
|
||||
let anchor = self.tabs[i]
|
||||
@@ -1036,6 +1048,9 @@ impl OpenCADStudio {
|
||||
self.dyn_coord_absolute = false;
|
||||
self.sync_dyn_fields();
|
||||
self.reset_tracking_after_point();
|
||||
if let Some((_, dir)) = distance_ray {
|
||||
self.snapper.remember_distance_reference(dir);
|
||||
}
|
||||
self.push_ucs_to_cmd(i);
|
||||
|
||||
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt))?;
|
||||
@@ -1383,7 +1398,7 @@ impl OpenCADStudio {
|
||||
if let Some(idx) = new_idx {
|
||||
self.tabs[i].layers.selected = Some(idx);
|
||||
self.tabs[i].layers.selected_multi = vec![idx];
|
||||
self.tabs[i].layers.editing = Some(idx);
|
||||
self.tabs[i].layers.editing = Some(new_name.clone());
|
||||
self.tabs[i].layers.edit_buf = new_name.clone();
|
||||
}
|
||||
self.sync_ribbon_layers();
|
||||
@@ -1569,16 +1584,18 @@ impl OpenCADStudio {
|
||||
|
||||
pub(super) fn on_layer_rename_commit(&mut self) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let editing_idx = self.tabs[i].layers.editing.take();
|
||||
if let Some(idx) = editing_idx {
|
||||
let editing_name = self.tabs[i].layers.editing.take();
|
||||
if let Some(old_name) = editing_name {
|
||||
let new_name = self.tabs[i].layers.edit_buf.trim().to_string();
|
||||
let old_name = self.tabs[i]
|
||||
// The name was captured at RenameStart; look it up fresh so
|
||||
// a sort/sync/filter in between cannot retarget the rename.
|
||||
// Deleted meanwhile → clear and no-op.
|
||||
let still_there = self.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.get(idx)
|
||||
.map(|l| l.name.clone())
|
||||
.unwrap_or_default();
|
||||
if !new_name.is_empty() && new_name != old_name {
|
||||
.iter()
|
||||
.any(|l| l.name == old_name);
|
||||
if !new_name.is_empty() && new_name != old_name && still_there {
|
||||
self.push_undo_snapshot(i, "LAYER RENAME");
|
||||
if !self.tabs[i].rename_layer(&old_name, &new_name) {
|
||||
self.discard_last_undo_entry(i);
|
||||
@@ -2305,7 +2322,15 @@ impl OpenCADStudio {
|
||||
self.tabs[i].dirty = true;
|
||||
}
|
||||
StyleKey::TableStyle => {
|
||||
self.ribbon.active_table_style = name;
|
||||
self.ribbon.active_table_style = name.clone();
|
||||
let i = self.active_tab;
|
||||
let found = self.tabs[i].scene.document.objects.values().any(
|
||||
|o| matches!(o, codec::objects::ObjectType::TableStyle(ts) if ts.name == name),
|
||||
);
|
||||
if found {
|
||||
self.tabs[i].scene.document.header.current_table_style_name = name;
|
||||
self.tabs[i].dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
@@ -4356,13 +4381,15 @@ mod layer_rename_tests {
|
||||
|
||||
fn rename_layer(app: &mut OpenCADStudio, old_name: &str, new_name: &str) {
|
||||
let i = app.active_tab;
|
||||
let idx = app.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter()
|
||||
.position(|layer| layer.name == old_name)
|
||||
.expect("layer exists in panel");
|
||||
app.tabs[i].layers.editing = Some(idx);
|
||||
assert!(
|
||||
app.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter()
|
||||
.any(|layer| layer.name == old_name),
|
||||
"layer exists in panel"
|
||||
);
|
||||
app.tabs[i].layers.editing = Some(old_name.to_string());
|
||||
app.tabs[i].layers.edit_buf = new_name.to_string();
|
||||
let _ = app.on_layer_rename_commit();
|
||||
}
|
||||
@@ -4407,11 +4434,17 @@ mod layer_rename_tests {
|
||||
fn current_layer_rename_updates_name_and_allocated_handle() {
|
||||
let mut app = app_with_editing_layer();
|
||||
let i = app.active_tab;
|
||||
let idx = app.tabs[i]
|
||||
let old_name = app.tabs[i]
|
||||
.layers
|
||||
.editing
|
||||
.clone()
|
||||
.expect("new layer is being edited");
|
||||
let old_name = app.tabs[i].layers.layers[idx].name.clone();
|
||||
let idx = app.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter()
|
||||
.position(|l| l.name == old_name)
|
||||
.expect("editing layer in panel");
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
@@ -4443,11 +4476,17 @@ mod layer_rename_tests {
|
||||
fn layer_rename_undo_redo_restores_active_layer() {
|
||||
let mut app = app_with_editing_layer();
|
||||
let i = app.active_tab;
|
||||
let idx = app.tabs[i]
|
||||
let old_name = app.tabs[i]
|
||||
.layers
|
||||
.editing
|
||||
.clone()
|
||||
.expect("new layer is being edited");
|
||||
let old_name = app.tabs[i].layers.layers[idx].name.clone();
|
||||
let idx = app.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter()
|
||||
.position(|l| l.name == old_name)
|
||||
.expect("editing layer in panel");
|
||||
app.tabs[i].layers.selected = Some(idx);
|
||||
let _ = app.on_layer_set_current();
|
||||
|
||||
@@ -4472,10 +4511,17 @@ mod layer_rename_tests {
|
||||
let old_name = app.tabs[i].layers.edit_buf.clone();
|
||||
rename_layer(&mut app, &old_name, "A");
|
||||
let _ = app.on_layer_new();
|
||||
let b_idx = app.tabs[i]
|
||||
let b_name = app.tabs[i]
|
||||
.layers
|
||||
.editing
|
||||
.clone()
|
||||
.expect("new layer is being edited");
|
||||
let b_idx = app.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter()
|
||||
.position(|l| l.name == b_name)
|
||||
.expect("editing layer in panel");
|
||||
app.tabs[i].layers.selected = Some(b_idx);
|
||||
let _ = app.on_layer_set_current();
|
||||
let header_name = app.tabs[i].scene.document.header.current_layer_name.clone();
|
||||
@@ -4538,13 +4584,15 @@ mod layer_name_target_tests {
|
||||
|
||||
fn rename_layer(app: &mut OpenCADStudio, old_name: &str, new_name: &str) {
|
||||
let i = app.active_tab;
|
||||
let idx = app.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter()
|
||||
.position(|layer| layer.name == old_name)
|
||||
.expect("layer exists in panel");
|
||||
app.tabs[i].layers.editing = Some(idx);
|
||||
assert!(
|
||||
app.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter()
|
||||
.any(|layer| layer.name == old_name),
|
||||
"layer exists in panel"
|
||||
);
|
||||
app.tabs[i].layers.editing = Some(old_name.to_string());
|
||||
app.tabs[i].layers.edit_buf = new_name.to_string();
|
||||
let _ = app.on_layer_rename_commit();
|
||||
}
|
||||
@@ -4596,6 +4644,135 @@ mod layer_name_target_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_rename_commit_follows_name_across_resort() {
|
||||
// #44: rename state keyed by name survives a panel re-sort; a stale
|
||||
// row index would rename the wrong layer.
|
||||
let mut app = app_with_zulu_alpha();
|
||||
let i = app.active_tab;
|
||||
app.tabs[i].layers.editing = Some("ZULU".to_string());
|
||||
app.tabs[i].layers.edit_buf = "ZULU_NEW".to_string();
|
||||
let _ = app.update(Message::LayerSort(LayerSortCol::Name));
|
||||
let _ = app.update(Message::LayerRenameCommit);
|
||||
let names: Vec<String> = app.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| l.name.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
names.contains(&"ZULU_NEW".to_string()),
|
||||
"ZULU must be renamed, got {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.contains(&"ZULU".to_string()),
|
||||
"old name must be gone, got {names:?}"
|
||||
);
|
||||
assert!(
|
||||
app.tabs[i].scene.document.layers.contains("ZULU_NEW"),
|
||||
"document must follow the rename"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_rename_commit_deleted_layer_is_noop() {
|
||||
// Renaming a layer deleted mid-edit must clear state without
|
||||
// renaming anything or pushing undo.
|
||||
let mut app = app_with_zulu_alpha();
|
||||
let i = app.active_tab;
|
||||
app.finish_pending_history(i);
|
||||
let undo_before = app.tabs[i].history.undo_stack.len();
|
||||
app.tabs[i].layers.editing = Some("GONE".to_string());
|
||||
app.tabs[i].layers.edit_buf = "NEW".to_string();
|
||||
let _ = app.update(Message::LayerRenameCommit);
|
||||
assert!(
|
||||
app.tabs[i].layers.editing.is_none(),
|
||||
"stale edit must clear"
|
||||
);
|
||||
assert!(
|
||||
app.tabs[i].layers.edit_buf.is_empty(),
|
||||
"stale edit buffer must clear"
|
||||
);
|
||||
assert_eq!(
|
||||
app.tabs[i].history.undo_stack.len(),
|
||||
undo_before,
|
||||
"no-op commit must not push undo"
|
||||
);
|
||||
assert!(
|
||||
!app.tabs[i].scene.document.layers.contains("NEW"),
|
||||
"nothing must be renamed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_rename_commit_empty_name_is_noop() {
|
||||
let mut app = app_with_zulu_alpha();
|
||||
let i = app.active_tab;
|
||||
app.finish_pending_history(i);
|
||||
let undo_before = app.tabs[i].history.undo_stack.len();
|
||||
app.tabs[i].layers.editing = Some("ZULU".to_string());
|
||||
app.tabs[i].layers.edit_buf = " ".to_string();
|
||||
let _ = app.update(Message::LayerRenameCommit);
|
||||
assert!(app.tabs[i].layers.editing.is_none(), "edit must clear");
|
||||
assert!(
|
||||
app.tabs[i].scene.document.layers.contains("ZULU"),
|
||||
"blank commit must not rename"
|
||||
);
|
||||
assert_eq!(
|
||||
app.tabs[i].history.undo_stack.len(),
|
||||
undo_before,
|
||||
"blank commit must not push undo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_color_picker_toggle_follows_name_across_resort() {
|
||||
// Same name-keying guarantee as rename, for the swatch row.
|
||||
let mut app = app_with_zulu_alpha();
|
||||
let _ = app.update(Message::LayerColorPickerToggle("ZULU".to_string()));
|
||||
let _ = app.update(Message::LayerSort(LayerSortCol::Name));
|
||||
let i = app.active_tab;
|
||||
assert_eq!(
|
||||
app.tabs[i].layers.color_picker_row,
|
||||
Some("ZULU".to_string()),
|
||||
"picker must stay on ZULU across a resort"
|
||||
);
|
||||
let _ = app.update(Message::LayerColorPickerToggle("ZULU".to_string()));
|
||||
assert!(
|
||||
app.tabs[i].layers.color_picker_row.is_none(),
|
||||
"second toggle must close the picker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_select_skips_filtered_out_rows() {
|
||||
// #50: rows hidden by the live filter must not join a shift-range.
|
||||
// Sorted panel: A1(0), B9(1), C1(2); filter "1" hides B9.
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
for new in ["C1", "B9", "A1"] {
|
||||
let _ = app.on_layer_new();
|
||||
let fresh = app.tabs[app.active_tab].layers.edit_buf.clone();
|
||||
rename_layer(&mut app, &fresh, new);
|
||||
}
|
||||
let i = app.active_tab;
|
||||
app.tabs[i].layers.filter = "1".to_string();
|
||||
let _ = app.update(Message::LayerSelect("A1".to_string()));
|
||||
app.shift_down = true;
|
||||
let _ = app.update(Message::LayerSelect("C1".to_string()));
|
||||
app.shift_down = false;
|
||||
let multi = app.tabs[i].layers.selected_multi.clone();
|
||||
let names: Vec<String> = multi
|
||||
.iter()
|
||||
.filter_map(|&x| app.tabs[i].layers.layers.get(x).map(|l| l.name.clone()))
|
||||
.collect();
|
||||
assert!(
|
||||
!names.contains(&"B9".to_string()),
|
||||
"hidden row must not join the range, got {names:?}"
|
||||
);
|
||||
assert!(names.contains(&"A1".to_string()) && names.contains(&"C1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_toggle_visible_unknown_name_is_noop() {
|
||||
let mut app = app_with_zulu_alpha();
|
||||
@@ -4615,6 +4792,156 @@ mod layer_name_target_tests {
|
||||
.collect();
|
||||
assert_eq!(before, after, "unknown name must change nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_transparency_edit_pushes_undo() {
|
||||
// #43: every sibling layer arm pushes an undo entry; transparency must too.
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let i = app.active_tab;
|
||||
app.finish_pending_history(i);
|
||||
let before = app.tabs[i].history.undo_stack.len();
|
||||
let _ = app.update(Message::LayerTransparencyEdit(
|
||||
"0".to_string(),
|
||||
"50".to_string(),
|
||||
));
|
||||
assert_eq!(
|
||||
app.tabs[i].history.undo_stack.len(),
|
||||
before + 1,
|
||||
"transparency edit must push an undo entry like its siblings"
|
||||
);
|
||||
let panel = app.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter()
|
||||
.find(|l| l.name == "0")
|
||||
.expect("layer 0 in panel");
|
||||
assert_eq!(panel.transparency, 50);
|
||||
app.undo_active_tab();
|
||||
let panel = app.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter()
|
||||
.find(|l| l.name == "0")
|
||||
.expect("layer 0 in panel");
|
||||
assert_eq!(panel.transparency, 0, "undo must restore transparency");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tablestyle_ribbon_tests {
|
||||
// #45: picking a table style in the ribbon must write the document header
|
||||
// (like Text/Dim/MLeader), not just the ribbon mirror.
|
||||
use crate::app::OpenCADStudio;
|
||||
use crate::modules::StyleKey;
|
||||
|
||||
#[test]
|
||||
fn tablestyle_ribbon_pick_writes_header_and_dirties() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let i = app.active_tab;
|
||||
let mut extra = codec::objects::TableStyle::standard();
|
||||
extra.name = "EXTRA".to_string();
|
||||
extra.handle = app.tabs[i].scene.document.allocate_handle();
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.objects
|
||||
.insert(extra.handle, codec::objects::ObjectType::TableStyle(extra));
|
||||
app.tabs[i].dirty = false;
|
||||
let _ = app.on_ribbon_style_changed(StyleKey::TableStyle, "EXTRA".to_string());
|
||||
assert_eq!(
|
||||
app.tabs[i].scene.document.header.current_table_style_name, "EXTRA",
|
||||
"ribbon pick must reach the document header"
|
||||
);
|
||||
assert!(
|
||||
app.tabs[i].dirty,
|
||||
"ribbon pick must dirty the tab like its siblings"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tablestyle_pick_flows_into_table_insert() {
|
||||
// End-to-end: ribbon pick → header → insert dialog defaults to the pick.
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let i = app.active_tab;
|
||||
let mut extra = codec::objects::TableStyle::standard();
|
||||
extra.name = "EXTRA".to_string();
|
||||
extra.handle = app.tabs[i].scene.document.allocate_handle();
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.objects
|
||||
.insert(extra.handle, codec::objects::ObjectType::TableStyle(extra));
|
||||
let _ = app.on_ribbon_style_changed(StyleKey::TableStyle, "EXTRA".to_string());
|
||||
app.open_table_insert();
|
||||
assert_eq!(
|
||||
app.table_insert.style, "EXTRA",
|
||||
"insert dialog must default to the ribbon-picked style"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tablestyle_buffer_tests {
|
||||
// #53: New/Copy/Delete must reload Table/MLine buffers (no stale edits
|
||||
// into the fresh style), and cell text-style typos must not persist.
|
||||
use crate::app::{OpenCADStudio, StyleKind};
|
||||
|
||||
#[test]
|
||||
fn table_new_reloads_cell_buffers() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
app.ts_cell_textstyle[0] = "STALE_SENTINEL".to_string();
|
||||
app.style_new(StyleKind::Table);
|
||||
assert_ne!(
|
||||
app.ts_cell_textstyle[0], "STALE_SENTINEL",
|
||||
"buffers must reload from the fresh style"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_cell_unknown_text_style_is_rejected() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let i = app.active_tab;
|
||||
let style_name = app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.objects
|
||||
.values()
|
||||
.find_map(|o| match o {
|
||||
codec::objects::ObjectType::TableStyle(s) => Some(s.name.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("fresh doc ships a table style");
|
||||
app.tablestyle_selected = style_name.clone();
|
||||
app.ts_cell_textstyle[0] = "NOPE".to_string();
|
||||
let before = app.command_line.history.len();
|
||||
let _ = app.on_table_style_cell_apply(0);
|
||||
let cell_name = app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.objects
|
||||
.values()
|
||||
.find_map(|o| match o {
|
||||
codec::objects::ObjectType::TableStyle(s) if s.name == style_name => {
|
||||
Some(s.data_row_style.text_style_name.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_ne!(cell_name, "NOPE", "dangling name must not persist");
|
||||
assert_eq!(
|
||||
app.ts_cell_textstyle[0], "NOPE",
|
||||
"rejected text stays in the buffer for correction"
|
||||
);
|
||||
assert!(
|
||||
app.command_line.history.len() > before,
|
||||
"rejection must say something"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -289,6 +289,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
},
|
||||
Message::DynTabNext => Some(self.on_context_menu_navigate(ContextMenuNav::Down)),
|
||||
Message::DynTabPrev => Some(self.on_context_menu_navigate(ContextMenuNav::Up)),
|
||||
Message::CommandFinalize | Message::CommandSpace => {
|
||||
if self.command_line.input.trim().is_empty() {
|
||||
Some(self.on_context_menu_navigate(ContextMenuNav::Enter))
|
||||
|
||||
+56
-20
@@ -1514,13 +1514,42 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xref_rowop_unknown_id_is_noop() {
|
||||
// #49 RED: row messages carry (key, saved_path) identity and resolve
|
||||
// at dispatch; a deleted row's id must no-op instead of hitting whatever
|
||||
// row now sits at the old index. Fails to compile until the payload
|
||||
// carries XrefRowId instead of usize.
|
||||
use crate::app::Message;
|
||||
use crate::ui::window::xref_manager::{XrefPaletteOp, XrefRowId};
|
||||
let mut app = fresh();
|
||||
let i = app.active_tab;
|
||||
let start = app.command_line.history.len();
|
||||
let ghost = XrefRowId {
|
||||
key: u64::MAX,
|
||||
saved_path: "gone.dwg".to_string(),
|
||||
};
|
||||
let records_before = app.tabs[i].scene.document.block_records.iter().count();
|
||||
let _ = app.update(Message::XrefRowOp(ghost, XrefPaletteOp::Detach));
|
||||
assert_eq!(
|
||||
app.tabs[i].scene.document.block_records.iter().count(),
|
||||
records_before,
|
||||
"no-op must not disturb the document"
|
||||
);
|
||||
let out = palette_output(&app, start);
|
||||
assert!(
|
||||
!out.contains("detached"),
|
||||
"unknown id must not detach anything, got: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_rowop_nested_reports_per_entry() {
|
||||
// Behavioral matrix: every row op on a NESTED row via the GUI
|
||||
// message path. Nested rows must report per-entry errors (CLI
|
||||
// wording), never silently skip.
|
||||
use crate::app::Message;
|
||||
use crate::ui::window::xref_manager::XrefPaletteOp;
|
||||
use crate::ui::window::xref_manager::{XrefPaletteOp, XrefRowId};
|
||||
use codec::tables::BlockRecord;
|
||||
let dir = palette_tmpdir("nestedmatrix");
|
||||
let mut host_doc = codec::CadDocument::new();
|
||||
@@ -1539,13 +1568,14 @@ mod tests {
|
||||
app.tabs[i].scene.document.block_records.add(br).unwrap();
|
||||
app.tabs[i].current_path = Some(dir.join("app.dwg"));
|
||||
app.refresh_xref_manager();
|
||||
let idx = app
|
||||
let row = app
|
||||
.xref_manager
|
||||
.entries
|
||||
.iter()
|
||||
.position(|e| e.name == "INNER")
|
||||
.find(|e| e.name == "INNER")
|
||||
.expect("nested INNER listed");
|
||||
assert!(app.xref_manager.entries[idx].parent_key.is_some());
|
||||
assert!(row.parent_key.is_some());
|
||||
let row_id = XrefRowId::of(row.key, &row.saved_path);
|
||||
for op in [
|
||||
XrefPaletteOp::Detach,
|
||||
XrefPaletteOp::Unload,
|
||||
@@ -1554,7 +1584,7 @@ mod tests {
|
||||
XrefPaletteOp::Overlay,
|
||||
] {
|
||||
let start = app.command_line.history.len();
|
||||
let _ = app.update(Message::XrefRowOp(idx, op));
|
||||
let _ = app.update(Message::XrefRowOp(row_id.clone(), op));
|
||||
let out = palette_output(&app, start);
|
||||
assert!(
|
||||
out.contains("INNER"),
|
||||
@@ -1578,7 +1608,7 @@ mod tests {
|
||||
// Reproduction for "Detach/Unload from the palette do nothing":
|
||||
// drive the exact GUI message path (row right-click menu item).
|
||||
use crate::app::Message;
|
||||
use crate::ui::window::xref_manager::XrefPaletteOp;
|
||||
use crate::ui::window::xref_manager::{XrefPaletteOp, XrefRowId};
|
||||
use codec::tables::BlockRecord;
|
||||
let mut app = fresh();
|
||||
let i = app.active_tab;
|
||||
@@ -1591,14 +1621,16 @@ mod tests {
|
||||
}
|
||||
app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_repro_host.dwg"));
|
||||
app.refresh_xref_manager();
|
||||
let idx = app
|
||||
let plan = app
|
||||
.xref_manager
|
||||
.entries
|
||||
.iter()
|
||||
.position(|e| e.name == "PLAN")
|
||||
.expect("PLAN listed");
|
||||
.find(|e| e.name == "PLAN")
|
||||
.expect("PLAN listed")
|
||||
.clone();
|
||||
let plan_id = XrefRowId::of(plan.key, &plan.saved_path);
|
||||
let start = app.command_line.history.len();
|
||||
let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Detach));
|
||||
let _ = app.update(Message::XrefRowOp(plan_id, XrefPaletteOp::Detach));
|
||||
let out = palette_output(&app, start);
|
||||
assert!(out.contains("PLAN"), "detach output missing, got: {out:?}");
|
||||
assert!(
|
||||
@@ -1610,14 +1642,16 @@ mod tests {
|
||||
.is_none(),
|
||||
"PLAN definition must be gone"
|
||||
);
|
||||
let idx = app
|
||||
let site = app
|
||||
.xref_manager
|
||||
.entries
|
||||
.iter()
|
||||
.position(|e| e.name == "SITE")
|
||||
.expect("SITE listed");
|
||||
.find(|e| e.name == "SITE")
|
||||
.expect("SITE listed")
|
||||
.clone();
|
||||
let site_id = XrefRowId::of(site.key, &site.saved_path);
|
||||
let start = app.command_line.history.len();
|
||||
let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Unload));
|
||||
let _ = app.update(Message::XrefRowOp(site_id, XrefPaletteOp::Unload));
|
||||
let out = palette_output(&app, start);
|
||||
assert!(out.contains("SITE"), "unload output missing, got: {out:?}");
|
||||
assert_eq!(
|
||||
@@ -1637,7 +1671,7 @@ mod tests {
|
||||
// GUI message path: type flag flips, saved path clears.
|
||||
use crate::app::Message;
|
||||
use crate::io::xref_model::{Pathtype, RefType};
|
||||
use crate::ui::window::xref_manager::XrefPaletteOp;
|
||||
use crate::ui::window::xref_manager::{XrefPaletteOp, XrefRowId};
|
||||
use codec::tables::BlockRecord;
|
||||
let mut app = fresh();
|
||||
let i = app.active_tab;
|
||||
@@ -1648,14 +1682,16 @@ mod tests {
|
||||
app.tabs[i].scene.document.block_records.add(br).unwrap();
|
||||
app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_rowop_host.dwg"));
|
||||
app.refresh_xref_manager();
|
||||
let idx = app
|
||||
let plan = app
|
||||
.xref_manager
|
||||
.entries
|
||||
.iter()
|
||||
.position(|e| e.name == "PLAN")
|
||||
.expect("PLAN listed");
|
||||
.find(|e| e.name == "PLAN")
|
||||
.expect("PLAN listed")
|
||||
.clone();
|
||||
let plan_id = XrefRowId::of(plan.key, &plan.saved_path);
|
||||
let start = app.command_line.history.len();
|
||||
let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Overlay));
|
||||
let _ = app.update(Message::XrefRowOp(plan_id.clone(), XrefPaletteOp::Overlay));
|
||||
let out = palette_output(&app, start);
|
||||
assert!(out.contains("PLAN"), "got: {out:?}");
|
||||
let br = app.tabs[i]
|
||||
@@ -1676,7 +1712,7 @@ mod tests {
|
||||
);
|
||||
let start = app.command_line.history.len();
|
||||
let _ = app.update(Message::XrefRowOp(
|
||||
idx,
|
||||
plan_id,
|
||||
XrefPaletteOp::Pathtype(Pathtype::None),
|
||||
));
|
||||
let out = palette_output(&app, start);
|
||||
|
||||
@@ -682,6 +682,7 @@ impl OpenCADStudio {
|
||||
self.dyn_coord_absolute = false;
|
||||
self.sync_dyn_fields();
|
||||
self.reset_tracking_after_point();
|
||||
self.snapper.remember_distance_reference(dir);
|
||||
self.push_ucs_to_cmd(i);
|
||||
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt));
|
||||
let task = result.map(|r| self.apply_cmd_result(r))?;
|
||||
|
||||
+607
-54
@@ -432,10 +432,16 @@ fn plot_content_extents(content: &PlotContent) -> Option<(f64, f64, f64, f64)> {
|
||||
}
|
||||
|
||||
/// Stamp the owning entity's effective color index onto a plot wire so CTB
|
||||
/// plot-style lookups (`wire.aci > 0`) apply: an entity's own ACI 1-255 is
|
||||
/// used as-is, ByLayer resolves through the layer table, and true-color
|
||||
/// objects keep `aci = 0` ("0 means true-color" — no CTB mapping). Wires
|
||||
/// whose owner cannot be resolved keep the index they carry.
|
||||
/// plot-style lookups (`wire.aci > 0`) apply.
|
||||
///
|
||||
/// Direct entities are resolved here from their authored color/layer. Expanded
|
||||
/// block wires are different: `BlockCache` has already resolved their effective
|
||||
/// ACI through ByBlock and layer-0/ByLayer inheritance. Their wire name points
|
||||
/// at the host INSERT rather than the child entity that produced the geometry,
|
||||
/// so resolving that INSERT again would destroy the child's effective ACI.
|
||||
///
|
||||
/// In particular, a true-color INSERT on an indexed-color layer would overwrite
|
||||
/// an inherited child ACI with 0 and bypass the CTB entirely.
|
||||
fn plot_owner_aci(scene: &crate::scene::Scene, wire: &mut crate::scene::WireModel) {
|
||||
let Some(handle) = crate::scene::Scene::handle_from_wire_name(&wire.name) else {
|
||||
return;
|
||||
@@ -443,6 +449,14 @@ fn plot_owner_aci(scene: &crate::scene::Scene, wire: &mut crate::scene::WireMode
|
||||
let Some(entity) = scene.document.get_entity(handle) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Expanded block geometry already carries the effective ACI calculated by
|
||||
// BlockCache. `wire.name` identifies the host INSERT for picking, not the
|
||||
// block child whose color inheritance produced `wire.aci`.
|
||||
if matches!(entity, codec::EntityType::Insert(_)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let common = entity.common();
|
||||
wire.aci = match &common.color {
|
||||
codec::types::Color::Index(index) => *index,
|
||||
@@ -459,6 +473,74 @@ fn plot_owner_aci(scene: &crate::scene::Scene, wire: &mut crate::scene::WireMode
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod plot_owner_aci_tests {
|
||||
use super::plot_owner_aci;
|
||||
|
||||
#[test]
|
||||
fn expanded_block_wire_keeps_its_resolved_effective_aci() {
|
||||
let mut scene = crate::scene::Scene::new();
|
||||
|
||||
// Real-world regression case:
|
||||
// the INSERT itself has explicit true-color white, while its block
|
||||
// children are layer 0 + ByLayer. BlockCache resolves those children
|
||||
// through the INSERT's layer and stores that effective ACI on the wire.
|
||||
let mut insert = codec::entities::Insert::new("*U264", codec::types::Vector3::ZERO);
|
||||
insert.common.color = codec::types::Color::Rgb {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
};
|
||||
|
||||
let insert_handle = scene
|
||||
.document
|
||||
.add_entity(codec::EntityType::Insert(insert))
|
||||
.expect("insert must be added");
|
||||
|
||||
let mut wire = crate::scene::WireModel::default();
|
||||
wire.name = insert_handle.value().to_string();
|
||||
|
||||
// Pretend BlockCache resolved the layer-0 child to indexed color 5.
|
||||
// The plot preparation step must preserve it instead of replacing it
|
||||
// with ACI 0 from the host INSERT's true color.
|
||||
wire.aci = 5;
|
||||
|
||||
plot_owner_aci(&scene, &mut wire);
|
||||
|
||||
assert_eq!(
|
||||
wire.aci, 5,
|
||||
"expanded block geometry must keep the effective ACI already resolved by BlockCache"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_entity_still_refreshes_aci_from_its_owner() {
|
||||
let mut scene = crate::scene::Scene::new();
|
||||
|
||||
let mut line = codec::entities::Line::from_points(
|
||||
codec::types::Vector3::ZERO,
|
||||
codec::types::Vector3::new(1.0, 0.0, 0.0),
|
||||
);
|
||||
line.common.color = codec::types::Color::Index(1);
|
||||
|
||||
let line_handle = scene
|
||||
.document
|
||||
.add_entity(codec::EntityType::Line(line))
|
||||
.expect("line must be added");
|
||||
|
||||
let mut wire = crate::scene::WireModel::default();
|
||||
wire.name = line_handle.value().to_string();
|
||||
wire.aci = 5;
|
||||
|
||||
plot_owner_aci(&scene, &mut wire);
|
||||
|
||||
assert_eq!(
|
||||
wire.aci, 1,
|
||||
"ordinary entity plot wires must still resolve ACI from their owner"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn plot_scene_content(
|
||||
scene: &crate::scene::Scene,
|
||||
paper_space_last: bool,
|
||||
@@ -693,6 +775,8 @@ impl OpenCADStudio {
|
||||
polar: self.polar_mode,
|
||||
polar_increment_deg: self.polar_increment_deg,
|
||||
zoom_wheel_reversed: self.zoom_wheel_reversed,
|
||||
touchpad_pan: self.touchpad_pan,
|
||||
ui_scale_percent: self.ui_scale_percent,
|
||||
zoom_factor: self.zoom_factor,
|
||||
cursor_size: self.cursor_size,
|
||||
pick_box: self.pick_box,
|
||||
@@ -709,6 +793,7 @@ 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(),
|
||||
surface_associativity: crate::scene::model::sweep_model::surface_associativity(),
|
||||
ncopy_bind: self.ncopy_bind,
|
||||
cursor_type: self.cursor_type,
|
||||
crosshair_color: self.crosshair_color,
|
||||
@@ -793,6 +878,8 @@ impl OpenCADStudio {
|
||||
self.polar_mode = s.polar;
|
||||
self.polar_increment_deg = s.polar_increment_deg;
|
||||
self.zoom_wheel_reversed = s.zoom_wheel_reversed;
|
||||
self.touchpad_pan = s.touchpad_pan;
|
||||
self.ui_scale_percent = crate::app::settings::clamp_ui_scale(s.ui_scale_percent);
|
||||
self.zoom_factor = crate::app::settings::clamp_zoom_factor(s.zoom_factor);
|
||||
// Like the drafting-rotation field below, the Options field edits a
|
||||
// buffer rather than the value, so it is reseeded whenever the value
|
||||
@@ -819,6 +906,7 @@ 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::model::sweep_model::set_surface_associativity(s.surface_associativity);
|
||||
self.ncopy_bind = s.ncopy_bind;
|
||||
self.cursor_type = s.cursor_type;
|
||||
self.crosshair_color = s.crosshair_color;
|
||||
@@ -1299,6 +1387,9 @@ impl OpenCADStudio {
|
||||
self.shortcut_bindings
|
||||
.entry("F5".to_string())
|
||||
.or_insert_with(|| "ISOPLANE".to_string());
|
||||
self.shortcut_bindings
|
||||
.entry("SHIFT+TAB".to_string())
|
||||
.or_insert_with(|| "DYNTABPREV".to_string());
|
||||
}
|
||||
|
||||
/// Write the config only when it changed since the last write, so a toggle
|
||||
@@ -1735,6 +1826,22 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop Plot-dialog state that belongs to the previously active drawing.
|
||||
///
|
||||
/// Plot settings such as paper size and CTB are document/layout data.
|
||||
/// A newly opened DWG may reuse the same tab index and layout name as the
|
||||
/// startup drawing, so `(tab, layout)` alone is not enough to identify the
|
||||
/// context. Keeping that cache makes the first Plot dialog show the startup
|
||||
/// A4/no-CTB state; the second opening then appears correct only because the
|
||||
/// first one refreshed the runtime state.
|
||||
fn reset_plot_session_for_opened_document(&mut self) {
|
||||
self.plot_prev = None;
|
||||
self.plot_prev_context = None;
|
||||
self.plot_reconcile_printer_media = None;
|
||||
self.plot_setup_template = None;
|
||||
self.plot_window = None;
|
||||
}
|
||||
|
||||
pub(super) fn on_file_opened(
|
||||
&mut self,
|
||||
name: String,
|
||||
@@ -1917,6 +2024,11 @@ impl OpenCADStudio {
|
||||
// A file saved without the built-in Standard styles (foreign
|
||||
// or damaged) gets them re-seeded so nothing dangles (#366).
|
||||
crate::app::style_ops::ensure_standard_styles(&mut self.tabs[i].scene.document);
|
||||
// Copies made by earlier releases kept their source's dimension
|
||||
// association; such a file is reported as damaged elsewhere.
|
||||
crate::scene::dimension_assoc::drop_stray_dimension_reactors(
|
||||
&mut self.tabs[i].scene.document,
|
||||
);
|
||||
// Follow the file's saved current UCS from the moment it opens.
|
||||
self.tabs[i].adopt_active_ucs_from_header();
|
||||
// Adopt the drawing's own Ortho ($ORTHOMODE) and running OSNAP
|
||||
@@ -2004,6 +2116,12 @@ impl OpenCADStudio {
|
||||
self.tabs[i].scene.load_current_layout_state();
|
||||
self.tabs[i].refresh_active_ucs();
|
||||
}
|
||||
|
||||
// The tab may have been reused for the newly opened file. Any Plot
|
||||
// dialog cache belongs to the previous/startup drawing and must not
|
||||
// win over this DWG's embedded layout settings on the first Ctrl+P.
|
||||
self.reset_plot_session_for_opened_document();
|
||||
|
||||
// Object isolation is session-only. A newly opened drawing must
|
||||
// not inherit the previous tab's filter, and persisted entity
|
||||
// visibility remains independent (not an isolation session).
|
||||
@@ -3825,22 +3943,27 @@ impl OpenCADStudio {
|
||||
let previous_style = self.active_plot_style.clone();
|
||||
let previous_window = self.plot_window;
|
||||
|
||||
// Once Print All has an explicit override, reopening Options must keep
|
||||
// those settings instead of replacing them with the active layout's
|
||||
// page setup.
|
||||
let keep_print_all_override = self.print_all_settings_override;
|
||||
|
||||
// Refresh runtime data such as printers, paper sizes, plot styles and
|
||||
// named page setups.
|
||||
// Refresh runtime-only data such as printers, available media,
|
||||
// CTB names, scales and page-setup rows.
|
||||
//
|
||||
// `on_plot_dialog_open()` also seeds the editor from the active
|
||||
// layout. That is correct for normal PLOT, but Print All Options is a
|
||||
// shared batch override editor: its persisted user choices must win.
|
||||
let task = self.on_plot_dialog_open();
|
||||
|
||||
if keep_print_all_override {
|
||||
// Restore only the user-editable plot settings. Runtime lists populated
|
||||
// above remain intact.
|
||||
self.plot_dialog.copy_settings_from(&previous);
|
||||
self.active_plot_style = previous_style.clone();
|
||||
self.plot_window = previous_window;
|
||||
}
|
||||
// Restore only user-editable values. `copy_settings_from` deliberately
|
||||
// leaves the freshly refreshed runtime lists intact.
|
||||
//
|
||||
// Do this even on the first Print All Options opening after restarting
|
||||
// OCS. Previously this happened only when `print_all_settings_override`
|
||||
// was already true, so the first opening always fell back to the
|
||||
// active layout/default A4 and lost the persisted CTB selection.
|
||||
self.plot_dialog.copy_settings_from(&previous);
|
||||
|
||||
// Preserve the parsed CTB when it matches the restored selection.
|
||||
// If it is absent/stale, dialog_plot_style() can resolve it by name.
|
||||
self.active_plot_style = previous_style.clone();
|
||||
self.plot_window = previous_window;
|
||||
|
||||
self.print_all_options_prev = Some(previous);
|
||||
self.print_all_plot_style_prev = Some(previous_style);
|
||||
@@ -4372,7 +4495,15 @@ impl OpenCADStudio {
|
||||
/// layout's plot settings and the printers found on the system.
|
||||
pub(super) fn on_plot_dialog_open(&mut self) -> Task<Message> {
|
||||
use crate::io::paper_catalog::Orientation;
|
||||
|
||||
let cur = self.tabs[self.active_tab].scene.current_layout.clone();
|
||||
let same_context = self
|
||||
.plot_prev_context
|
||||
.as_ref()
|
||||
.is_some_and(|(tab, layout)| *tab == self.active_tab && layout == &cur);
|
||||
|
||||
let previous = self.plot_dialog.clone();
|
||||
|
||||
let scales: Vec<(String, f64)> = self.tabs[self.active_tab]
|
||||
.scene
|
||||
.scale_list()
|
||||
@@ -4457,30 +4588,71 @@ impl OpenCADStudio {
|
||||
self.command_line
|
||||
.push_warning(crate::tf!("Could not list printers: {error}").as_ref());
|
||||
}
|
||||
self.plot_prev = Some(previous);
|
||||
let cur = self.tabs[self.active_tab].scene.current_layout.clone();
|
||||
self.plot_prev = Some(previous.clone());
|
||||
let layout_entry = format!("*{cur}*");
|
||||
// The auto-applied page setup must not reset the output device: a
|
||||
// drawing rarely stores one, so honouring it would snap the dialog
|
||||
// back to the system default on every open. The user's last choice
|
||||
// (persisted across sessions with the rest of the dialog) survives
|
||||
// unless the setup names an explicit device of its own.
|
||||
let kept_printer = self.plot_dialog.printer.clone();
|
||||
let kept_to_file = self.plot_dialog.to_file;
|
||||
if self.tabs[self.active_tab]
|
||||
.scene
|
||||
.plot_settings_for(&cur)
|
||||
.is_some()
|
||||
{
|
||||
self.select_page_setup(&layout_entry);
|
||||
|
||||
if same_context {
|
||||
// #1654: reopening Plot for the same drawing/layout must keep the
|
||||
// values the user just edited. The layout page setup is the initial
|
||||
// seed, not something that should overwrite paper, scale, CTB,
|
||||
// orientation, area and output choices on every Ctrl+P.
|
||||
//
|
||||
// `copy_settings_from` deliberately leaves refreshed runtime lists
|
||||
// (printers, media, scales, page setups, etc.) untouched.
|
||||
self.plot_dialog.copy_settings_from(&previous);
|
||||
self.plot_dialog.selected_setup = previous.selected_setup.clone();
|
||||
|
||||
// A custom ratio such as 1:3 may not exist in the drawing's named
|
||||
// scale list. select_page_setup() normally adds it while loading a
|
||||
// PlotSettings, but same-context reopening intentionally skips that
|
||||
// reload. Keep the restored custom scale available to the picker so
|
||||
// the validation below does not collapse it to 1:1.
|
||||
if !self.plot_dialog.fit_to_paper
|
||||
&& !self
|
||||
.plot_dialog
|
||||
.scales
|
||||
.iter()
|
||||
.any(|(name, _)| name == &self.plot_dialog.scale)
|
||||
{
|
||||
let factor = plot_dialog_scale_factor(&self.plot_dialog);
|
||||
|
||||
if factor.is_finite() && factor > 0.0 {
|
||||
let name = self.plot_dialog.scale.clone();
|
||||
self.plot_dialog.scales.push((name, factor));
|
||||
self.plot_dialog
|
||||
.scales
|
||||
.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.select_page_setup(crate::ui::window::plot::SETUP_PREV);
|
||||
}
|
||||
let setup_named_a_device = self.plot_dialog.printer.is_some() || self.plot_dialog.to_file;
|
||||
if !setup_named_a_device {
|
||||
self.plot_dialog.printer = kept_printer;
|
||||
self.plot_dialog.to_file = kept_to_file;
|
||||
// First open for this tab/layout: seed the editor from its actual
|
||||
// page setup. An explicit setup selection later still uses
|
||||
// `select_page_setup` and therefore intentionally replaces fields.
|
||||
//
|
||||
// Keep the chosen output device when a drawing page setup does not
|
||||
// name one of its own.
|
||||
let kept_printer = self.plot_dialog.printer.clone();
|
||||
let kept_to_file = self.plot_dialog.to_file;
|
||||
|
||||
if self.tabs[self.active_tab]
|
||||
.scene
|
||||
.plot_settings_for(&cur)
|
||||
.is_some()
|
||||
{
|
||||
self.select_page_setup(&layout_entry);
|
||||
} else {
|
||||
self.select_page_setup(crate::ui::window::plot::SETUP_PREV);
|
||||
}
|
||||
|
||||
let setup_named_a_device =
|
||||
self.plot_dialog.printer.is_some() || self.plot_dialog.to_file;
|
||||
if !setup_named_a_device {
|
||||
self.plot_dialog.printer = kept_printer;
|
||||
self.plot_dialog.to_file = kept_to_file;
|
||||
}
|
||||
}
|
||||
|
||||
self.plot_prev_context = Some((self.active_tab, cur.clone()));
|
||||
// A persisted plot window (saved with the dialog settings) restores
|
||||
// across sessions; a freshly loaded one wins so the two stay in sync.
|
||||
if self.plot_window.is_none() {
|
||||
@@ -4510,25 +4682,43 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
self.active_modal = Some(crate::app::ModalKind::Plot);
|
||||
self.request_printer_media()
|
||||
// Loading printer capabilities is informational when Plot merely
|
||||
// opens. It must not replace the sheet stored in the drawing.
|
||||
self.request_printer_media(false)
|
||||
}
|
||||
|
||||
/// Keep the dialog's view of the selected printer's media current: use the
|
||||
/// session cache when the printer was asked before, otherwise ask it in
|
||||
/// the background and clear the stale answer meanwhile. PDF output and
|
||||
/// the default printer list the catalogue instead.
|
||||
fn request_printer_media(&mut self) -> Task<Message> {
|
||||
let d = &mut self.plot_dialog;
|
||||
let Some(printer) = (!d.to_file).then(|| d.printer.clone()).flatten() else {
|
||||
d.printer_media = None;
|
||||
fn request_printer_media(&mut self, reconcile: bool) -> Task<Message> {
|
||||
let Some(printer) = (!self.plot_dialog.to_file)
|
||||
.then(|| self.plot_dialog.printer.clone())
|
||||
.flatten()
|
||||
else {
|
||||
self.plot_dialog.printer_media = None;
|
||||
self.plot_reconcile_printer_media = None;
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
// PAPERUPDATE is relevant after the user explicitly changes the
|
||||
// device/page setup. Merely reopening Plot must leave the drawing's
|
||||
// stored paper untouched.
|
||||
self.plot_reconcile_printer_media = reconcile.then(|| printer.clone());
|
||||
|
||||
if let Some(caps) = crate::io::plot_device::cached_printer_capabilities(&printer) {
|
||||
d.printer_media = Some(caps);
|
||||
self.plot_dialog.printer_media = Some(caps);
|
||||
|
||||
if reconcile {
|
||||
self.reconcile_sheet_with_printer(&printer);
|
||||
}
|
||||
|
||||
return Task::none();
|
||||
}
|
||||
d.printer_media = None;
|
||||
|
||||
self.plot_dialog.printer_media = None;
|
||||
let name = printer.clone();
|
||||
|
||||
background_task(
|
||||
crate::t!("Printer capabilities"),
|
||||
move || crate::io::plot_device::printer_capabilities(&printer),
|
||||
@@ -4703,6 +4893,9 @@ impl OpenCADStudio {
|
||||
};
|
||||
match msg {
|
||||
M::Close => {
|
||||
// Keep the user's last Plot choices available for the next
|
||||
// invocation even when the dialog was simply closed.
|
||||
self.save_config();
|
||||
self.close_active_modal();
|
||||
Task::none()
|
||||
}
|
||||
@@ -4720,14 +4913,31 @@ impl OpenCADStudio {
|
||||
self.plot_dialog.printer = Some(s);
|
||||
}
|
||||
self.save_config();
|
||||
self.request_printer_media()
|
||||
// This is an explicit device change, so PAPERUPDATE may
|
||||
// reconcile an unsupported sheet when its capabilities arrive.
|
||||
self.request_printer_media(true)
|
||||
}
|
||||
M::PrinterMedia(printer, caps) => {
|
||||
let d = &mut self.plot_dialog;
|
||||
if !d.to_file && d.printer.as_deref() == Some(printer.as_str()) {
|
||||
d.printer_media = caps;
|
||||
self.reconcile_sheet_with_printer(&printer);
|
||||
let still_current = !self.plot_dialog.to_file
|
||||
&& self.plot_dialog.printer.as_deref() == Some(printer.as_str());
|
||||
|
||||
if still_current {
|
||||
self.plot_dialog.printer_media = caps;
|
||||
|
||||
let reconcile = self
|
||||
.plot_reconcile_printer_media
|
||||
.as_deref()
|
||||
.is_some_and(|pending| pending.eq_ignore_ascii_case(&printer));
|
||||
|
||||
if reconcile {
|
||||
// Keep the explicit-device context alive while this
|
||||
// printer remains selected. Capability updates for the
|
||||
// same user-selected device must continue to obey
|
||||
// PAPERUPDATE.
|
||||
self.reconcile_sheet_with_printer(&printer);
|
||||
}
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
M::CustomPaper(msg) => {
|
||||
@@ -4991,13 +5201,44 @@ impl OpenCADStudio {
|
||||
Task::done(Message::Command("PLOTWINDOW".into()))
|
||||
}
|
||||
M::SelectSetup(name) => {
|
||||
// Print All Options edits the page setups represented by the
|
||||
// layout rows. With "Save changes to layout" enabled, preserve
|
||||
// the setup we are leaving before another one is loaded.
|
||||
if self.print_all_options
|
||||
&& self.plot_dialog.save_to_layout
|
||||
&& self.plot_dialog.selected_setup != name
|
||||
{
|
||||
let previous = self.plot_dialog.selected_setup.clone();
|
||||
|
||||
if is_layout_entry(&previous) {
|
||||
let layout = layout_entry_name(&previous).to_string();
|
||||
self.apply_dialog_to_named_layout(&layout);
|
||||
} else if previous == crate::ui::window::plot::SETUP_PREV {
|
||||
// `<previous>` is a transient snapshot, not an actual
|
||||
// page setup. `plot_prev_context` records which layout
|
||||
// that snapshot came from.
|
||||
//
|
||||
// If the user returns to that same layout, commit the
|
||||
// restored snapshot to it before reloading the row.
|
||||
if let Some((tab, layout)) = self.plot_prev_context.clone() {
|
||||
let target = format!("*{layout}*");
|
||||
|
||||
if tab == self.active_tab && name == target {
|
||||
self.apply_dialog_to_named_layout(&layout);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.select_page_setup(&name);
|
||||
|
||||
if self.print_all_options {
|
||||
self.plot_dialog.paper_space = true;
|
||||
self.plot_dialog.area = "Layout".into();
|
||||
}
|
||||
|
||||
// The setup may name a different printer.
|
||||
self.request_printer_media()
|
||||
self.request_printer_media(true)
|
||||
}
|
||||
M::SetCurrent => {
|
||||
self.apply_dialog_to_layout();
|
||||
@@ -5425,6 +5666,38 @@ impl OpenCADStudio {
|
||||
self.apply_plot_page_settings();
|
||||
}
|
||||
|
||||
/// Persist the current Plot editor into a specific layout.
|
||||
///
|
||||
/// This is needed by Print All Options because selecting `*Layout1*`,
|
||||
/// `*Layout2*`, etc. does not switch `scene.current_layout`.
|
||||
fn apply_dialog_to_named_layout(&mut self, layout_name: &str) {
|
||||
use codec::objects::{PlotPaperUnits, PlotSettings};
|
||||
|
||||
let i = self.active_tab;
|
||||
|
||||
let base = self.tabs[i]
|
||||
.scene
|
||||
.plot_settings_for(layout_name)
|
||||
.unwrap_or_else(|| {
|
||||
let mut settings = PlotSettings::new("");
|
||||
settings.paper_units = PlotPaperUnits::Millimeters;
|
||||
settings
|
||||
});
|
||||
|
||||
let ps = self.plot_settings_from_dialog(base);
|
||||
|
||||
if self.tabs[i]
|
||||
.scene
|
||||
.set_layout_plot_settings(layout_name, &ps)
|
||||
{
|
||||
self.tabs[i].dirty = true;
|
||||
|
||||
if self.tabs[i].scene.current_layout == layout_name {
|
||||
self.tabs[i].scene.bump_geometry_no_blocks();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a preview PDF, export a PDF, or send the job to the chosen printer.
|
||||
fn on_plot_dlg_commit(&mut self, preview: bool) -> Task<Message> {
|
||||
let d = self.plot_dialog.clone();
|
||||
@@ -5576,10 +5849,21 @@ impl OpenCADStudio {
|
||||
if d.style_name.is_empty() || d.style_missing || !d.apply_plot_styles {
|
||||
return None;
|
||||
}
|
||||
self.active_plot_style
|
||||
|
||||
// Fast path: the selected table is already active.
|
||||
if let Some(table) = self
|
||||
.active_plot_style
|
||||
.as_ref()
|
||||
.filter(|table| table.name.eq_ignore_ascii_case(&d.style_name))
|
||||
.cloned()
|
||||
{
|
||||
return Some(table.clone());
|
||||
}
|
||||
|
||||
// The dialog selection is persisted separately from the parsed CTB
|
||||
// object. After reopening a drawing the name may be correct while the
|
||||
// in-memory table is still the previous/default one. Plotting must not
|
||||
// silently ignore the selected CTB in that state.
|
||||
crate::io::plot_style::PlotStyleTable::load_named(&d.style_name).ok()
|
||||
}
|
||||
|
||||
pub(in crate::app) fn window_plot_job(&self) -> Option<PdfPageInput> {
|
||||
@@ -6931,6 +7215,275 @@ mod plot_device_persistence_tests {
|
||||
assert!(!app.plot_dialog.to_file);
|
||||
}
|
||||
|
||||
/// Reopening Plot in the same tab/layout keeps the user's most recent
|
||||
/// paper, scale and CTB choices instead of reloading the layout page setup
|
||||
/// over them. Regression test for #1654.
|
||||
#[test]
|
||||
fn reopening_same_plot_context_keeps_recent_settings() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
|
||||
// First open seeds from the drawing/layout as usual.
|
||||
let _ = app.on_plot_dialog_open();
|
||||
|
||||
// User edits the high-frequency Plot settings.
|
||||
app.plot_dialog.paper = "ISO_A3_(297.00_x_420.00_MM)".into();
|
||||
app.plot_dialog.paper_width_mm = 297.0;
|
||||
app.plot_dialog.paper_height_mm = 420.0;
|
||||
app.plot_dialog.scale = "1:100".into();
|
||||
app.plot_dialog.fit_to_paper = false;
|
||||
app.plot_dialog.style_name = "office.ctb".into();
|
||||
app.plot_dialog.apply_plot_styles = true;
|
||||
|
||||
// Reopening Ctrl+P in the same layout must not reapply the layout's
|
||||
// old page setup and erase those choices.
|
||||
let _ = app.on_plot_dialog_open();
|
||||
|
||||
assert_eq!(app.plot_dialog.paper, "ISO_A3_(297.00_x_420.00_MM)");
|
||||
assert_eq!(app.plot_dialog.scale, "1:100");
|
||||
assert!(!app.plot_dialog.fit_to_paper);
|
||||
assert_eq!(app.plot_dialog.style_name, "office.ctb");
|
||||
assert!(app.plot_dialog.apply_plot_styles);
|
||||
}
|
||||
|
||||
/// Opening a different drawing in a reused tab must invalidate the Plot
|
||||
/// session cache. The first Plot dialog must read paper and CTB from the
|
||||
/// newly opened document instead of showing the startup drawing's values.
|
||||
#[test]
|
||||
fn newly_opened_document_forces_first_plot_dialog_to_read_layout() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let i = app.active_tab;
|
||||
|
||||
app.tabs[i].scene.current_layout = "Layout1".into();
|
||||
|
||||
let mut ps = app.tabs[i]
|
||||
.scene
|
||||
.plot_settings_for("Layout1")
|
||||
.expect("Layout1 must exist");
|
||||
|
||||
ps.paper_size = "ISO_A2_(420.00_x_594.00_MM)".into();
|
||||
ps.paper_width = 420.0;
|
||||
ps.paper_height = 594.0;
|
||||
ps.current_style_sheet = crate::io::plot_style::MONOCHROME_PLOT_STYLE.into();
|
||||
ps.flags.plot_plot_styles = true;
|
||||
|
||||
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
|
||||
|
||||
// Reproduce stale state inherited from the startup/previous drawing.
|
||||
app.plot_dialog.paper = "ISO_A4_(210.00_x_297.00_MM)".into();
|
||||
app.plot_dialog.style_name.clear();
|
||||
app.plot_dialog.apply_plot_styles = false;
|
||||
|
||||
app.plot_prev = Some(app.plot_dialog.clone());
|
||||
app.plot_prev_context = Some((i, "Layout1".into()));
|
||||
|
||||
// Installing another drawing must make that state unusable.
|
||||
app.reset_plot_session_for_opened_document();
|
||||
|
||||
assert!(app.plot_prev.is_none());
|
||||
assert!(app.plot_prev_context.is_none());
|
||||
|
||||
let _ = app.on_plot_dialog_open();
|
||||
|
||||
assert_eq!(
|
||||
app.plot_dialog.paper, "ISO_A2_(420.00_x_594.00_MM)",
|
||||
"the first Plot opening must use the new drawing's paper"
|
||||
);
|
||||
|
||||
assert!(
|
||||
app.plot_dialog
|
||||
.style_name
|
||||
.eq_ignore_ascii_case(crate::io::plot_style::MONOCHROME_PLOT_STYLE),
|
||||
"the first Plot opening must use the new drawing's CTB"
|
||||
);
|
||||
|
||||
assert!(
|
||||
app.plot_dialog.apply_plot_styles,
|
||||
"the stored CTB must already be enabled on the first Plot opening"
|
||||
);
|
||||
}
|
||||
|
||||
/// Print All Options is a shared batch-settings editor. On the first
|
||||
/// opening after restarting OCS it must keep the persisted user choices
|
||||
/// instead of replacing them with the active layout's page setup.
|
||||
#[test]
|
||||
fn print_all_options_keeps_persisted_plot_preferences_on_first_open() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
|
||||
// Simulate preferences restored from the previous application session.
|
||||
app.plot_dialog.paper = "ISO_A2_(420.00_x_594.00_MM)".into();
|
||||
app.plot_dialog.paper_width_mm = 420.0;
|
||||
app.plot_dialog.paper_height_mm = 594.0;
|
||||
app.plot_dialog.scale = "1:1000".into();
|
||||
app.plot_dialog.style_name = crate::io::plot_style::MONOCHROME_PLOT_STYLE.into();
|
||||
app.plot_dialog.apply_plot_styles = true;
|
||||
|
||||
// Fresh process: no explicit Print All override exists yet.
|
||||
app.print_all_settings_override = false;
|
||||
|
||||
let _ = app.on_print_all_options();
|
||||
|
||||
assert_eq!(
|
||||
app.plot_dialog.paper, "ISO_A2_(420.00_x_594.00_MM)",
|
||||
"first Print All Options opening must keep the persisted paper"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
app.plot_dialog.scale, "1:1000",
|
||||
"first Print All Options opening must keep the persisted scale"
|
||||
);
|
||||
|
||||
assert!(
|
||||
app.plot_dialog
|
||||
.style_name
|
||||
.eq_ignore_ascii_case(crate::io::plot_style::MONOCHROME_PLOT_STYLE),
|
||||
"first Print All Options opening must keep the persisted CTB"
|
||||
);
|
||||
|
||||
assert!(
|
||||
app.plot_dialog.apply_plot_styles,
|
||||
"the persisted CTB must remain enabled"
|
||||
);
|
||||
|
||||
assert!(
|
||||
app.print_all_options,
|
||||
"the dialog must remain in Print All Options mode"
|
||||
);
|
||||
}
|
||||
|
||||
/// `<previous>` belongs to the layout that produced the snapshot.
|
||||
/// Returning to that layout with "Save changes to layout" enabled must
|
||||
/// preserve the restored paper/CTB instead of reloading the old embedded
|
||||
/// page setup over it.
|
||||
#[test]
|
||||
fn previous_snapshot_is_saved_back_to_its_origin_layout() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let i = app.active_tab;
|
||||
|
||||
app.tabs[i].scene.current_layout = "Layout1".into();
|
||||
|
||||
// Layout1 still contains an old page setup.
|
||||
let mut stored = app.tabs[i]
|
||||
.scene
|
||||
.plot_settings_for("Layout1")
|
||||
.expect("Layout1 must exist");
|
||||
|
||||
stored.paper_size = "ISO_A4_(210.00_x_297.00_MM)".into();
|
||||
stored.paper_width = 210.0;
|
||||
stored.paper_height = 297.0;
|
||||
stored.current_style_sheet.clear();
|
||||
stored.flags.plot_plot_styles = false;
|
||||
|
||||
assert!(app.tabs[i]
|
||||
.scene
|
||||
.set_layout_plot_settings("Layout1", &stored));
|
||||
|
||||
// `<previous>` is the desired state that originally belonged to
|
||||
// Layout1.
|
||||
app.print_all_options = true;
|
||||
app.plot_prev_context = Some((i, "Layout1".into()));
|
||||
app.plot_dialog.selected_setup = crate::ui::window::plot::SETUP_PREV.into();
|
||||
|
||||
app.plot_dialog.paper = "ISO_A2_(420.00_x_594.00_MM)".into();
|
||||
app.plot_dialog.paper_width_mm = 420.0;
|
||||
app.plot_dialog.paper_height_mm = 594.0;
|
||||
app.plot_dialog.style_name = crate::io::plot_style::MONOCHROME_PLOT_STYLE.into();
|
||||
app.plot_dialog.apply_plot_styles = true;
|
||||
app.plot_dialog.save_to_layout = true;
|
||||
|
||||
let _ = app.on_plot_dlg(crate::ui::window::plot::PlotDlgMsg::SelectSetup(
|
||||
"*Layout1*".into(),
|
||||
));
|
||||
|
||||
let saved = app.tabs[i]
|
||||
.scene
|
||||
.plot_settings_for("Layout1")
|
||||
.expect("Layout1 must still exist");
|
||||
|
||||
assert_eq!(saved.paper_size, "ISO_A2_(420.00_x_594.00_MM)");
|
||||
|
||||
assert!(saved
|
||||
.current_style_sheet
|
||||
.eq_ignore_ascii_case(crate::io::plot_style::MONOCHROME_PLOT_STYLE));
|
||||
|
||||
assert!(saved.flags.plot_plot_styles);
|
||||
|
||||
assert_eq!(app.plot_dialog.paper, "ISO_A2_(420.00_x_594.00_MM)");
|
||||
|
||||
assert!(app
|
||||
.plot_dialog
|
||||
.style_name
|
||||
.eq_ignore_ascii_case(crate::io::plot_style::MONOCHROME_PLOT_STYLE));
|
||||
}
|
||||
|
||||
/// A CTB selected in the dialog must be usable even when the parsed
|
||||
/// active table was not restored with it. This is the state seen after
|
||||
/// reopening a drawing: the dropdown can name the correct CTB while the
|
||||
/// first preview otherwise plots using raw layer colours.
|
||||
#[test]
|
||||
fn selected_ctb_is_resolved_when_active_table_is_stale() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
|
||||
app.plot_dialog.style_name = crate::io::plot_style::MONOCHROME_PLOT_STYLE.into();
|
||||
app.plot_dialog.apply_plot_styles = true;
|
||||
app.plot_dialog.style_missing = false;
|
||||
|
||||
// Reproduce a fresh-session mismatch: the visible selection exists,
|
||||
// but no matching parsed table is active yet.
|
||||
app.active_plot_style = None;
|
||||
|
||||
let table = app
|
||||
.dialog_plot_style(&app.plot_dialog)
|
||||
.expect("selected CTB must be resolved by name");
|
||||
|
||||
assert!(
|
||||
table
|
||||
.name
|
||||
.eq_ignore_ascii_case(crate::io::plot_style::MONOCHROME_PLOT_STYLE),
|
||||
"the table used for plotting must match the visible CTB selection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Printer capability discovery on merely opening Plot must never mutate
|
||||
/// the paper stored in the drawing. PAPERUPDATE is applied only after an
|
||||
/// explicit device/page-setup choice.
|
||||
#[test]
|
||||
fn opening_plot_does_not_replace_sheet_when_printer_media_arrives() {
|
||||
use crate::io::paper_catalog::{resolve, Margins};
|
||||
use crate::io::plot_device::{PrinterCapabilities, PrinterMedia};
|
||||
|
||||
let a2 = resolve("ISO_A2_(420.00_x_594.00_MM)").expect("A2");
|
||||
let a4 = resolve("ISO_A4_(210.00_x_297.00_MM)").expect("A4");
|
||||
|
||||
let caps = std::sync::Arc::new(PrinterCapabilities {
|
||||
media: vec![PrinterMedia {
|
||||
paper: a4.clone(),
|
||||
margins: Margins::uniform(3.0),
|
||||
borderless: false,
|
||||
}],
|
||||
default_paper: Some(a4),
|
||||
});
|
||||
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.plot_dialog.to_file = false;
|
||||
app.plot_dialog.printer = Some("OCS Test Printer".into());
|
||||
app.plot_dialog.paper = a2.canonical.to_string();
|
||||
app.plot_dialog.paper_update = 1;
|
||||
|
||||
// No explicit printer change is pending: this models the asynchronous
|
||||
// capability reply caused only by opening the Plot dialog.
|
||||
app.plot_reconcile_printer_media = None;
|
||||
|
||||
let _ = app.on_plot_dlg(crate::ui::window::plot::PlotDlgMsg::PrinterMedia(
|
||||
"OCS Test Printer".into(),
|
||||
Some(caps),
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
app.plot_dialog.paper, a2.canonical,
|
||||
"opening Plot must preserve the drawing's stored sheet"
|
||||
);
|
||||
}
|
||||
|
||||
/// A layout whose plot settings name an explicit device wins over the
|
||||
/// saved preference: selecting its setup is an explicit device choice.
|
||||
#[test]
|
||||
|
||||
+294
-98
@@ -24,6 +24,7 @@ fn is_modal_blocked_key_msg(msg: &Message) -> bool {
|
||||
| Message::CommandLineArrowProbe { .. }
|
||||
| Message::CommandLineArrowResolved { .. }
|
||||
| Message::DynTabNext
|
||||
| Message::DynTabPrev
|
||||
| Message::MTextCaretMove(_)
|
||||
| Message::DeleteSelected
|
||||
| Message::ToggleSnapEnabled
|
||||
@@ -533,7 +534,9 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
if let Some(started) = perf_started {
|
||||
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
let elapsed_us = started.elapsed().as_micros() as u64;
|
||||
crate::perf::note_update_time(elapsed_us);
|
||||
let elapsed_ms = elapsed_us as f64 / 1000.0;
|
||||
if elapsed_ms >= 5.0 {
|
||||
crate::perf_record!("[perf] update {:>7.1}ms message={perf_label}", elapsed_ms,);
|
||||
}
|
||||
@@ -2165,16 +2168,21 @@ impl OpenCADStudio {
|
||||
let i = self.active_tab;
|
||||
// Capture prompt/options while holding cmd borrow, then release before
|
||||
// borrowing command_line to satisfy borrow checker.
|
||||
let (should_update, opts, prompt) =
|
||||
let (should_update, opts, prompt, suggestions) =
|
||||
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||||
if cmd.on_live_input(&live_input) {
|
||||
(true, cmd.options(), cmd.prompt())
|
||||
let changed = cmd.on_live_input(&live_input);
|
||||
let suggestions = cmd.text_suggestions(&live_input);
|
||||
if changed {
|
||||
(true, cmd.options(), cmd.prompt(), suggestions)
|
||||
} else {
|
||||
(false, Vec::new(), String::new())
|
||||
(false, Vec::new(), String::new(), suggestions)
|
||||
}
|
||||
} else {
|
||||
(false, Vec::new(), String::new())
|
||||
(false, Vec::new(), String::new(), Vec::new())
|
||||
};
|
||||
|
||||
self.command_line.set_contextual_suggestions(suggestions);
|
||||
|
||||
if should_update {
|
||||
self.command_line.set_step_options(opts);
|
||||
// Update pinned prompt text live without pushing new history entry
|
||||
@@ -2203,6 +2211,12 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
Message::DynTabNext => {
|
||||
if self.command_line.has_contextual_suggestions()
|
||||
&& self.command_line.autocomplete_next()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
let i = self.active_tab;
|
||||
let n = self.tabs[i].dyn_fields.len();
|
||||
if n > 0 {
|
||||
@@ -2214,6 +2228,13 @@ impl OpenCADStudio {
|
||||
self.focus_cmd_input()
|
||||
}
|
||||
|
||||
Message::DynTabPrev => {
|
||||
if self.command_line.has_contextual_suggestions() {
|
||||
self.command_line.autocomplete_prev();
|
||||
}
|
||||
self.focus_cmd_input()
|
||||
}
|
||||
|
||||
Message::SplitModelViewport(horizontal) => {
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].scene.split_active_pane(horizontal);
|
||||
@@ -2250,7 +2271,8 @@ impl OpenCADStudio {
|
||||
}
|
||||
let i = self.active_tab;
|
||||
if !self.command_line.history_navigation_active()
|
||||
&& self.tabs[i].active_cmd.is_none()
|
||||
&& (self.tabs[i].active_cmd.is_none()
|
||||
|| self.command_line.has_contextual_suggestions())
|
||||
&& self.command_line.autocomplete_prev()
|
||||
{
|
||||
return Task::none();
|
||||
@@ -2276,7 +2298,8 @@ impl OpenCADStudio {
|
||||
}
|
||||
let i = self.active_tab;
|
||||
if !self.command_line.history_navigation_active()
|
||||
&& self.tabs[i].active_cmd.is_none()
|
||||
&& (self.tabs[i].active_cmd.is_none()
|
||||
|| self.command_line.has_contextual_suggestions())
|
||||
&& self.command_line.autocomplete_next()
|
||||
{
|
||||
return Task::none();
|
||||
@@ -2477,11 +2500,20 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
Message::CommandSuggestionPick(cmd) => {
|
||||
Message::CommandSuggestionPick(value) => {
|
||||
self.command_line.close_history();
|
||||
|
||||
if self.tabs[self.active_tab].active_cmd.is_some()
|
||||
&& self.command_line.has_contextual_suggestions()
|
||||
{
|
||||
self.command_line.input = value;
|
||||
self.command_line.autocomplete_cursor = None;
|
||||
return self.on_command_submit();
|
||||
}
|
||||
|
||||
self.command_line.input.clear();
|
||||
self.command_line.autocomplete_cursor = None;
|
||||
self.command_line.close_history();
|
||||
self.dispatch_command(&cmd)
|
||||
self.dispatch_command(&value)
|
||||
}
|
||||
|
||||
Message::CommandOptionPick(kw) => {
|
||||
@@ -2584,7 +2616,7 @@ impl OpenCADStudio {
|
||||
self.refresh_xref_manager();
|
||||
Task::none()
|
||||
}
|
||||
Message::XrefManagerSelect(index) => {
|
||||
Message::XrefManagerSelect(id) => {
|
||||
use crate::ui::window::xref_manager::SelectExtend;
|
||||
// Normal GUI list behavior off the globally tracked
|
||||
// modifiers (same source as the layer list): plain click
|
||||
@@ -2596,12 +2628,12 @@ impl OpenCADStudio {
|
||||
} else {
|
||||
SelectExtend::Single
|
||||
};
|
||||
self.xref_manager.click_select(index, extend);
|
||||
self.xref_manager.click_select(&id, extend);
|
||||
Task::none()
|
||||
}
|
||||
Message::XrefRowRightClick(index) => {
|
||||
Message::XrefRowRightClick(id) => {
|
||||
self.xref_manager.row_change_path_open = false;
|
||||
self.xref_manager.right_click_select(index);
|
||||
self.xref_manager.right_click_select(&id);
|
||||
Task::none()
|
||||
}
|
||||
Message::XrefManagerToggleTree => {
|
||||
@@ -2744,67 +2776,76 @@ impl OpenCADStudio {
|
||||
self.xref_manager_op(op);
|
||||
Task::none()
|
||||
}
|
||||
Message::XrefRowOp(index, op) => {
|
||||
Message::XrefRowOp(id, op) => {
|
||||
// Open is not a palette op — it navigates to the file.
|
||||
if op == crate::ui::window::xref_manager::XrefPaletteOp::Open {
|
||||
self.xref_manager.right_click_select(index);
|
||||
self.xref_manager.right_click_select(&id);
|
||||
self.xref_manager.row_change_path_open = false;
|
||||
if let Some(entry) = self.xref_manager.entries.get(index) {
|
||||
if let Some(found) = entry.found_at.clone() {
|
||||
let is_dwg = found.to_ascii_lowercase().ends_with(".dwg")
|
||||
|| found.to_ascii_lowercase().ends_with(".dxf");
|
||||
self.command_line
|
||||
.push_output(crate::tf!("XOPEN: opening \"{}\".", found).as_ref());
|
||||
if is_dwg {
|
||||
return self
|
||||
.update(Message::OpenRecent(std::path::PathBuf::from(found)));
|
||||
if let Some(index) = self.xref_manager.row_index(&id) {
|
||||
if let Some(entry) = self.xref_manager.entries.get(index) {
|
||||
if let Some(found) = entry.found_at.clone() {
|
||||
let is_dwg = found.to_ascii_lowercase().ends_with(".dwg")
|
||||
|| found.to_ascii_lowercase().ends_with(".dxf");
|
||||
self.command_line.push_output(
|
||||
crate::tf!("XOPEN: opening \"{}\".", found).as_ref(),
|
||||
);
|
||||
if is_dwg {
|
||||
return self.update(Message::OpenRecent(
|
||||
std::path::PathBuf::from(found),
|
||||
));
|
||||
} else {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = open::that_detached(&found);
|
||||
}
|
||||
} else {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = open::that_detached(&found);
|
||||
self.command_line.push_error(
|
||||
crate::t!("File not found — check the saved path.").as_ref(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.command_line.push_error(
|
||||
crate::t!("File not found — check the saved path.").as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
// Attach prompts the file dialog to pick a drawing to attach.
|
||||
if matches!(op, crate::ui::window::xref_manager::XrefPaletteOp::Attach) {
|
||||
self.xref_manager.right_click_select(index);
|
||||
self.xref_manager.right_click_select(&id);
|
||||
self.xref_manager.row_change_path_open = false;
|
||||
self.command_line.push_output(crate::t!("XATTACH").as_ref());
|
||||
return self.update(Message::XAttachPick);
|
||||
}
|
||||
// Row-scoped op: select the row first, then run the op.
|
||||
self.xref_manager.right_click_select(index);
|
||||
self.xref_manager.right_click_select(&id);
|
||||
self.xref_manager.row_change_path_open = false;
|
||||
self.xref_manager_op(op);
|
||||
Task::none()
|
||||
}
|
||||
Message::XrefRowPathPick(index) => {
|
||||
self.xref_manager.right_click_select(index);
|
||||
Message::XrefRowPathPick(id) => {
|
||||
self.xref_manager.right_click_select(&id);
|
||||
self.xref_manager.row_change_path_open = false;
|
||||
self.update(Message::XrefPathPick)
|
||||
}
|
||||
Message::XrefRowFindReplacePrompt(index) => {
|
||||
self.xref_manager.right_click_select(index);
|
||||
Message::XrefRowFindReplacePrompt(id) => {
|
||||
self.xref_manager.right_click_select(&id);
|
||||
self.xref_manager.row_change_path_open = false;
|
||||
let prefill = if let Some(entry) = self.xref_manager.entries.get(index) {
|
||||
let saved = &entry.saved_path;
|
||||
if let Some(parent) = std::path::Path::new(saved)
|
||||
.parent()
|
||||
.and_then(|p| p.to_str())
|
||||
{
|
||||
if !parent.is_empty() {
|
||||
format!("XREF Path Find \"{}\" ", parent)
|
||||
} else {
|
||||
"XREF Path Find ".to_string()
|
||||
}
|
||||
} else {
|
||||
"XREF Path Find ".to_string()
|
||||
}
|
||||
let prefill = if let Some(index) = self.xref_manager.row_index(&id) {
|
||||
self.xref_manager.entries.get(index).map_or_else(
|
||||
|| "XREF Path Find ".to_string(),
|
||||
|entry| {
|
||||
let saved = &entry.saved_path;
|
||||
if let Some(parent) = std::path::Path::new(saved)
|
||||
.parent()
|
||||
.and_then(|p| p.to_str())
|
||||
{
|
||||
if !parent.is_empty() {
|
||||
format!("XREF Path Find \"{}\" ", parent)
|
||||
} else {
|
||||
"XREF Path Find ".to_string()
|
||||
}
|
||||
} else {
|
||||
"XREF Path Find ".to_string()
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
"XREF Path Find ".to_string()
|
||||
};
|
||||
@@ -3559,10 +3600,14 @@ impl OpenCADStudio {
|
||||
panel.selected_multi.push(idx);
|
||||
}
|
||||
} else if shift {
|
||||
// Shift-click selects the range from the anchor to here.
|
||||
// Shift-click selects the range from the anchor to here —
|
||||
// visible rows only, so filtered-out rows between them
|
||||
// cannot join bulk edits/deletes (#50).
|
||||
let anchor = panel.selected.unwrap_or(idx);
|
||||
let (lo, hi) = (anchor.min(idx), anchor.max(idx));
|
||||
panel.selected_multi = (lo..=hi).collect();
|
||||
let visible: std::collections::HashSet<usize> =
|
||||
panel.visible_indices().into_iter().collect();
|
||||
panel.selected_multi = (lo..=hi).filter(|r| visible.contains(r)).collect();
|
||||
} else if !panel.selected_multi.contains(&idx) {
|
||||
// Plain click collapses to this row — but NOT when it is
|
||||
// already part of a multi-selection, so clicking a property
|
||||
@@ -3581,10 +3626,8 @@ impl OpenCADStudio {
|
||||
};
|
||||
self.tabs[i].layers.selected = Some(idx);
|
||||
self.tabs[i].layers.selected_multi = vec![idx];
|
||||
if let Some(layer) = self.tabs[i].layers.layers.get(idx) {
|
||||
self.tabs[i].layers.edit_buf = layer.name.clone();
|
||||
}
|
||||
self.tabs[i].layers.editing = Some(idx);
|
||||
self.tabs[i].layers.edit_buf = name.clone();
|
||||
self.tabs[i].layers.editing = Some(name);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
@@ -3602,11 +3645,11 @@ impl OpenCADStudio {
|
||||
return Task::none();
|
||||
};
|
||||
let panel = &mut self.tabs[i].layers;
|
||||
if panel.color_picker_row == Some(idx) {
|
||||
if panel.color_picker_row.as_deref() == Some(name.as_str()) {
|
||||
panel.color_picker_row = None;
|
||||
panel.color_full_palette = false;
|
||||
} else {
|
||||
panel.color_picker_row = Some(idx);
|
||||
panel.color_picker_row = Some(name);
|
||||
panel.color_full_palette = false;
|
||||
panel.selected = Some(idx);
|
||||
// Opening the swatch on a row outside the current
|
||||
@@ -3719,23 +3762,25 @@ impl OpenCADStudio {
|
||||
// Apply the edited transparency to every selected layer (#236).
|
||||
if let Some(v) = val {
|
||||
let targets = self.layer_row_action_targets(i, idx);
|
||||
for name in &targets {
|
||||
if let Some(layer) = self.tabs[i].scene.document.layers.get_mut(name) {
|
||||
layer.transparency =
|
||||
codec::types::Transparency::from_percent(v as f64 / 100.0);
|
||||
}
|
||||
if let Some(pl) = self.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter_mut()
|
||||
.find(|l| &l.name == name)
|
||||
{
|
||||
pl.transparency = v;
|
||||
}
|
||||
}
|
||||
if !targets.is_empty() {
|
||||
let undo = self.begin_layer_undo(i, "LAYER TRANSPARENCY", &targets);
|
||||
for name in &targets {
|
||||
if let Some(layer) = self.tabs[i].scene.document.layers.get_mut(name) {
|
||||
layer.transparency =
|
||||
codec::types::Transparency::from_percent(v as f64 / 100.0);
|
||||
}
|
||||
if let Some(pl) = self.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter_mut()
|
||||
.find(|l| &l.name == name)
|
||||
{
|
||||
pl.transparency = v;
|
||||
}
|
||||
}
|
||||
self.tabs[i].scene.invalidate_layer_dependencies(&targets);
|
||||
self.tabs[i].dirty = true;
|
||||
self.commit_layer_undo(i, undo);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
@@ -4558,28 +4603,36 @@ impl OpenCADStudio {
|
||||
}
|
||||
Message::AnnoObjectScaleToggle(name) => {
|
||||
let i = self.active_tab;
|
||||
if let Some(entity) = self.anno_object_scale_target {
|
||||
if self.tabs[i].scene.is_layer_locked(entity) {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(sh) = self.tabs[i].scene.scale_handle_ensuring(&name) {
|
||||
self.push_undo_snapshot(i, "OBJECTSCALE");
|
||||
let doc = &mut self.tabs[i].scene.document;
|
||||
let is_member =
|
||||
crate::scene::annotative::object_scale_memberships(doc, entity)
|
||||
.iter()
|
||||
.any(|(_, h)| *h == sh);
|
||||
if is_member {
|
||||
crate::scene::annotative::remove_annotation_context_for_scale(
|
||||
doc, entity, sh,
|
||||
);
|
||||
} else {
|
||||
crate::scene::annotative::create_annotation_context(doc, entity, sh);
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.invalidate_property_targets(i, &[entity]);
|
||||
self.refresh_properties();
|
||||
let Some(entity) = self.anno_object_scale_target else {
|
||||
self.command_line.push_info(
|
||||
crate::t!("Select one object first, then run OBJECTSCALE.").as_ref(),
|
||||
);
|
||||
return Task::none();
|
||||
};
|
||||
if self.tabs[i].scene.is_layer_locked(entity) {
|
||||
self.command_line
|
||||
.push_info(crate::t!("The object is on a locked layer.").as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(sh) = self.tabs[i].scene.scale_handle_ensuring(&name) {
|
||||
self.push_undo_snapshot(i, "OBJECTSCALE");
|
||||
let doc = &mut self.tabs[i].scene.document;
|
||||
let is_member = crate::scene::annotative::object_scale_memberships(doc, entity)
|
||||
.iter()
|
||||
.any(|(_, h)| *h == sh);
|
||||
if is_member {
|
||||
crate::scene::annotative::remove_annotation_context_for_scale(
|
||||
doc, entity, sh,
|
||||
);
|
||||
} else {
|
||||
crate::scene::annotative::create_annotation_context(doc, entity, sh);
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.invalidate_property_targets(i, &[entity]);
|
||||
self.refresh_properties();
|
||||
} else {
|
||||
self.command_line
|
||||
.push_info(crate::tf!("Unknown annotation scale '{name}'.").as_ref());
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@@ -6892,11 +6945,12 @@ impl OpenCADStudio {
|
||||
};
|
||||
let Some((sx, sy, sz)) = scales else { return };
|
||||
let eq = (sx - sy).abs() < 1e-12 && (sx - sz).abs() < 1e-12;
|
||||
let checked = eq && !app.props_asym_scale.contains(&handle.value());
|
||||
let checked =
|
||||
eq && !app.tabs[i].props_asym_scale.contains(&handle.value());
|
||||
if checked {
|
||||
app.props_asym_scale.insert(handle.value());
|
||||
app.tabs[i].props_asym_scale.insert(handle.value());
|
||||
} else {
|
||||
app.props_asym_scale.remove(&handle.value());
|
||||
app.tabs[i].props_asym_scale.remove(&handle.value());
|
||||
if let Some(codec::EntityType::Insert(ins)) =
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
@@ -8411,6 +8465,18 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::TouchpadPanChanged(enabled) => {
|
||||
self.touchpad_pan = enabled;
|
||||
self.persist_settings_if_changed();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::UiScaleChanged(percent) => {
|
||||
self.ui_scale_percent = crate::app::settings::clamp_ui_scale(percent);
|
||||
self.persist_settings_if_changed();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::ZoomFactorChanged(factor) => {
|
||||
self.zoom_factor = crate::app::settings::clamp_zoom_factor(factor);
|
||||
// The field beside the slider reads the same value, so the
|
||||
@@ -11124,6 +11190,136 @@ mod prop_pointer_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod anno_scale_toggle_tests {
|
||||
// #59: silent no-op toggles must explain themselves on the command line.
|
||||
use super::Message;
|
||||
use crate::app::OpenCADStudio;
|
||||
|
||||
fn app_with_target() -> (OpenCADStudio, usize) {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let i = app.active_tab;
|
||||
let mut line = codec::entities::Line::new();
|
||||
line.start = codec::types::Vector3::ZERO;
|
||||
line.end = codec::types::Vector3::new(1.0, 0.0, 0.0);
|
||||
let h = app
|
||||
.commit_entity_handle(codec::EntityType::Line(line))
|
||||
.expect("line commits");
|
||||
app.anno_object_scale_target = Some(h);
|
||||
(app, i)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_layer_toggle_explains_itself() {
|
||||
let (mut app, i) = app_with_target();
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.get_mut("0")
|
||||
.expect("layer 0 exists")
|
||||
.flags
|
||||
.locked = true;
|
||||
let before = app.command_line.history.len();
|
||||
let _ = app.update(Message::AnnoObjectScaleToggle("1:50".to_string()));
|
||||
let new: Vec<String> = app.command_line.history[before..]
|
||||
.iter()
|
||||
.map(|e| e.text.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
new.iter().any(|t| t.to_lowercase().contains("locked")),
|
||||
"locked-layer toggle must say so, got {new:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_scale_toggle_explains_itself() {
|
||||
let (mut app, _i) = app_with_target();
|
||||
let before = app.command_line.history.len();
|
||||
let _ = app.update(Message::AnnoObjectScaleToggle("NOPE".to_string()));
|
||||
let new: Vec<String> = app.command_line.history[before..]
|
||||
.iter()
|
||||
.map(|e| e.text.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
!new.is_empty(),
|
||||
"unknown-scale toggle must say something, got silence"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_target_toggle_explains_itself() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
app.anno_object_scale_target = None;
|
||||
let before = app.command_line.history.len();
|
||||
let _ = app.update(Message::AnnoObjectScaleToggle("1:50".to_string()));
|
||||
let new: Vec<String> = app.command_line.history[before..]
|
||||
.iter()
|
||||
.map(|e| e.text.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
!new.is_empty(),
|
||||
"target-less toggle must say something, got silence"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dock_visibility_tests {
|
||||
// #57: one visibility predicate for edge rendering and drag math. Pins
|
||||
// the full 8-panel table so a newly added panel cannot silently diverge
|
||||
// (behavior-preserving dedup: passes before and after by construction).
|
||||
use crate::app::OpenCADStudio;
|
||||
use crate::ui::dock::PanelId;
|
||||
|
||||
const ALL: [PanelId; 8] = [
|
||||
PanelId::Properties,
|
||||
PanelId::BlockPalette,
|
||||
PanelId::ExternalReferences,
|
||||
PanelId::Browser,
|
||||
PanelId::NodeGraph,
|
||||
PanelId::PointCloudManager,
|
||||
PanelId::Count,
|
||||
PanelId::SheetSetManager,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn dock_panel_visible_covers_every_panel() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
app.show_properties = true;
|
||||
app.show_block_palette = true;
|
||||
app.show_external_references = true;
|
||||
app.show_browser = true;
|
||||
app.show_node_graph = true;
|
||||
app.pc_manager.show = true;
|
||||
app.count_palette.show = true;
|
||||
app.sheet_set.show = true;
|
||||
for id in ALL {
|
||||
assert!(app.dock_panel_visible(id), "{id:?} must be visible");
|
||||
}
|
||||
app.clean_screen = true;
|
||||
for id in ALL {
|
||||
assert!(
|
||||
!app.dock_panel_visible(id),
|
||||
"{id:?} must hide on clean screen"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_panel_hidden_on_start_tab() {
|
||||
// Fresh app, no drawing opened: the welcome tab shows no panels.
|
||||
let app = OpenCADStudio::new_for_test();
|
||||
assert!(app.tabs[app.active_tab].is_start);
|
||||
for id in ALL {
|
||||
assert!(!app.dock_panel_visible(id), "{id:?} must hide on Start");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod free_text_entry_tests {
|
||||
//! The free-form text prompts (table cell content, TEXT / MTEXT bodies)
|
||||
|
||||
+17
-2
@@ -695,12 +695,23 @@ impl OpenCADStudio {
|
||||
self.ts_border_spacing[r][b].trim().parse().ok(),
|
||||
)
|
||||
});
|
||||
// Reject dangling references: a typo must not persist as a text
|
||||
// style that does not exist (#53). Decided up front so the
|
||||
// cell borrow below does not conflict with command-line I/O.
|
||||
let ts_known = ts.is_empty()
|
||||
|| self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.text_styles
|
||||
.iter()
|
||||
.any(|s| s.name.eq_ignore_ascii_case(&ts));
|
||||
let ts_rejected = !ts.is_empty() && !ts_known;
|
||||
if let Some(c) = self
|
||||
.tablestyle_mut(i)
|
||||
.and_then(|s| Self::ts_cell_of(s, row))
|
||||
{
|
||||
if !ts.is_empty() {
|
||||
c.text_style_name = ts;
|
||||
if !ts.is_empty() && ts_known {
|
||||
c.text_style_name = ts.clone();
|
||||
}
|
||||
if let Some(h) = height {
|
||||
c.text_height = h;
|
||||
@@ -732,6 +743,10 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
}
|
||||
if ts_rejected {
|
||||
self.command_line
|
||||
.push_error(crate::tf!("Unknown text style '{ts}'.").as_ref());
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
|
||||
+163
-196
@@ -42,9 +42,6 @@ enum ScrollIntent {
|
||||
Zoom { notches: f32 },
|
||||
/// Trackpad movement to pan by, in screen pixels.
|
||||
///
|
||||
/// Built only where a pixel delta can only mean a trackpad, so it has no
|
||||
/// constructor on the other targets.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
Pan { dx: f32, dy: f32 },
|
||||
}
|
||||
|
||||
@@ -55,11 +52,23 @@ const ZOOM_DIR_FLIP_PX: f32 = 2.0;
|
||||
|
||||
/// A wheel notch zooms; a trackpad's two fingers pan.
|
||||
///
|
||||
/// A wheel reports notches, a precise-scrolling device reports pixels, and that
|
||||
/// is the whole distinction — there is no modifier to check and no setting to
|
||||
/// read. The pixels only mean "trackpad" on macOS: the browser build reports
|
||||
/// every wheel notch as pixels too, so everywhere else they keep zooming.
|
||||
fn scroll_intent(delta: mouse::ScrollDelta) -> ScrollIntent {
|
||||
/// On macOS a wheel reports notches and a trackpad pixels, and that is the
|
||||
/// whole distinction. Elsewhere a touchpad's two-finger drag arrives as wheel
|
||||
/// notches (the browser build reports every wheel as pixels), so nothing tells
|
||||
/// it from a wheel: with `touchpad_pan` on, every scroll pans and Ctrl+scroll —
|
||||
/// what a pinch sends there — zooms.
|
||||
fn scroll_intent(delta: mouse::ScrollDelta, touchpad_pan: bool, ctrl: bool) -> ScrollIntent {
|
||||
/// Screen pixels one wheel line pans.
|
||||
const LINE_PX: f32 = 40.0;
|
||||
if touchpad_pan && !ctrl {
|
||||
return match delta {
|
||||
mouse::ScrollDelta::Pixels { x, y } => ScrollIntent::Pan { dx: x, dy: y },
|
||||
mouse::ScrollDelta::Lines { x, y } => ScrollIntent::Pan {
|
||||
dx: x * LINE_PX,
|
||||
dy: y * LINE_PX,
|
||||
},
|
||||
};
|
||||
}
|
||||
match delta {
|
||||
// Both axes go straight through: the two-finger gesture moves the
|
||||
// drawing the same way the middle-button drag moves it. If a pan ever
|
||||
@@ -626,6 +635,107 @@ impl OpenCADStudio {
|
||||
/// cursor exactly where the GPU draws it; otherwise the model/paper camera
|
||||
/// and the normal hit-test wires. `bounds` is the pane-local rectangle.
|
||||
|
||||
/// The entities a selection box from `a` to `p` (tile pixels) catches,
|
||||
/// as a window or crossing, through the wire, hatch and mesh tests — the
|
||||
/// one box test the drag and click-move-click gestures and automation
|
||||
/// share. The selection filter is the caller's.
|
||||
pub(in crate::app) fn box_selection_handles(
|
||||
&self,
|
||||
i: usize,
|
||||
edit_cam: &Option<crate::scene::view::camera::Camera>,
|
||||
bounds: iced::Rectangle,
|
||||
a: iced::Point,
|
||||
p: iced::Point,
|
||||
crossing: bool,
|
||||
) -> (Vec<Handle>, BoxSelectTiming) {
|
||||
let (view_rot, eye, all_wires) = self.pick_view(i, edit_cam, bounds);
|
||||
let world_aabb = [a, iced::Point::new(a.x, p.y), p, iced::Point::new(p.x, a.y)]
|
||||
.map(|point| self.cursor_model_point(i, edit_cam, point, bounds))
|
||||
.into_iter()
|
||||
.fold(
|
||||
[
|
||||
f64::INFINITY,
|
||||
f64::INFINITY,
|
||||
f64::NEG_INFINITY,
|
||||
f64::NEG_INFINITY,
|
||||
],
|
||||
|mut aabb, world| {
|
||||
aabb[0] = aabb[0].min(world.x);
|
||||
aabb[1] = aabb[1].min(world.y);
|
||||
aabb[2] = aabb[2].max(world.x);
|
||||
aabb[3] = aabb[3].max(world.y);
|
||||
aabb
|
||||
},
|
||||
);
|
||||
// A large box selection was slow and two guesses about which step
|
||||
// owns the time were both wrong, so each step is timed.
|
||||
let elapsed = |start: Option<Instant>| {
|
||||
start.map_or(0.0, |start| start.elapsed().as_secs_f64() * 1000.0)
|
||||
};
|
||||
let mut timing = BoxSelectTiming::default();
|
||||
let t_sel = crate::perf::enabled().then(Instant::now);
|
||||
let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb(
|
||||
all_wires,
|
||||
world_aabb,
|
||||
[a.x.min(p.x), a.y.min(p.y), a.x.max(p.x), a.y.max(p.y)],
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
);
|
||||
timing.candidates = elapsed(t_sel);
|
||||
let t_hit = crate::perf::enabled().then(Instant::now);
|
||||
let scene = &self.tabs[i].scene;
|
||||
let candidate_handles = scene.interaction_candidate_handles(&area_candidates);
|
||||
timing.handles = elapsed(t_hit);
|
||||
let mut handles: Vec<Handle> =
|
||||
scene::pick::hit_test::box_hit(a, p, crossing, &area_candidates, view_rot, eye, bounds)
|
||||
.into_iter()
|
||||
.filter_map(|s| Scene::handle_from_wire_name(s))
|
||||
.collect();
|
||||
timing.wires = elapsed(t_hit);
|
||||
handles.extend(scene::pick::hit_test::box_hit_hatch(
|
||||
a,
|
||||
p,
|
||||
crossing,
|
||||
&scene.visible_hatches_for_click(candidate_handles.as_ref()),
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
candidate_handles.as_ref(),
|
||||
));
|
||||
handles.extend(scene::pick::hit_test::box_hit_insert_hatch(
|
||||
a,
|
||||
p,
|
||||
crossing,
|
||||
scene.insert_hatches_for_click().as_ref(),
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
candidate_handles.as_ref(),
|
||||
));
|
||||
timing.hatch = elapsed(t_hit);
|
||||
handles.extend(scene.mesh_box_hit(
|
||||
a,
|
||||
p,
|
||||
crossing,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
candidate_handles.as_ref(),
|
||||
));
|
||||
handles.extend(scene.block_mesh_box_hit(
|
||||
a,
|
||||
p,
|
||||
crossing,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
candidate_handles.as_ref(),
|
||||
));
|
||||
timing.hit = elapsed(t_hit);
|
||||
(handles, timing)
|
||||
}
|
||||
|
||||
pub(in crate::app) fn pick_view(
|
||||
&self,
|
||||
i: usize,
|
||||
@@ -1090,6 +1200,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
pub(in crate::app) fn on_viewport_move(&mut self, p: Point) -> Task<Message> {
|
||||
crate::perf::note_pointer_move();
|
||||
// A ribbon dropdown is open over the viewport. Its backdrop
|
||||
// cannot swallow cursor motion — in iced 0.14 mouse_area/opaque
|
||||
// capture only button presses, never CursorMoved — so the move
|
||||
@@ -4861,101 +4972,17 @@ impl OpenCADStudio {
|
||||
// it here on release.
|
||||
if let Some(a) = box_anchor {
|
||||
let crossing = box_crossing;
|
||||
let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
|
||||
let world_aabb =
|
||||
[a, iced::Point::new(a.x, p.y), p, iced::Point::new(p.x, a.y)]
|
||||
.map(|point| self.cursor_model_point(i, &edit_cam, point, bounds))
|
||||
.into_iter()
|
||||
.fold(
|
||||
[
|
||||
f64::INFINITY,
|
||||
f64::INFINITY,
|
||||
f64::NEG_INFINITY,
|
||||
f64::NEG_INFINITY,
|
||||
],
|
||||
|mut aabb, world| {
|
||||
aabb[0] = aabb[0].min(world.x);
|
||||
aabb[1] = aabb[1].min(world.y);
|
||||
aabb[2] = aabb[2].max(world.x);
|
||||
aabb[3] = aabb[3].max(world.y);
|
||||
aabb
|
||||
},
|
||||
);
|
||||
// A 186 k-entity box selection sits at ~800 ms in this
|
||||
// handler and two guesses about which step owns it have
|
||||
// both been wrong. Split it.
|
||||
let t_sel = crate::perf::enabled().then(Instant::now);
|
||||
let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb(
|
||||
all_wires,
|
||||
world_aabb,
|
||||
[a.x.min(p.x), a.y.min(p.y), a.x.max(p.x), a.y.max(p.y)],
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
);
|
||||
let cand_ms = t_sel.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
|
||||
let t_hit = crate::perf::enabled().then(Instant::now);
|
||||
let candidate_handles = self.tabs[i]
|
||||
.scene
|
||||
.interaction_candidate_handles(&area_candidates);
|
||||
let mut handles: Vec<Handle> = scene::pick::hit_test::box_hit(
|
||||
a,
|
||||
p,
|
||||
crossing,
|
||||
&area_candidates,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
)
|
||||
.into_iter()
|
||||
.filter_map(|s| Scene::handle_from_wire_name(s))
|
||||
.collect();
|
||||
handles.extend(scene::pick::hit_test::box_hit_hatch(
|
||||
a,
|
||||
p,
|
||||
crossing,
|
||||
&self.tabs[i]
|
||||
.scene
|
||||
.visible_hatches_for_click(candidate_handles.as_ref()),
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
candidate_handles.as_ref(),
|
||||
));
|
||||
handles.extend(scene::pick::hit_test::box_hit_insert_hatch(
|
||||
a,
|
||||
p,
|
||||
crossing,
|
||||
self.tabs[i].scene.insert_hatches_for_click().as_ref(),
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
candidate_handles.as_ref(),
|
||||
));
|
||||
handles.extend(self.tabs[i].scene.mesh_box_hit(
|
||||
a,
|
||||
p,
|
||||
crossing,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
candidate_handles.as_ref(),
|
||||
));
|
||||
handles.extend(self.tabs[i].scene.block_mesh_box_hit(
|
||||
a,
|
||||
p,
|
||||
crossing,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
candidate_handles.as_ref(),
|
||||
));
|
||||
let (mut handles, timing) =
|
||||
self.box_selection_handles(i, &edit_cam, bounds, a, p, crossing);
|
||||
// The drag gesture applies the selection filter as the click one does.
|
||||
handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h));
|
||||
let cand_ms = timing.candidates;
|
||||
// Box/lasso accumulates like individual picks
|
||||
// (issue #83): a plain box adds to the current
|
||||
// selection, Shift+box removes the boxed
|
||||
// entities. Esc / empty-space click still clears.
|
||||
// PICKADD 0 (#226): a plain box REPLACES.
|
||||
let hit_ms = t_hit.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
|
||||
let hit_ms = timing.hit;
|
||||
let t_apply = crate::perf::enabled().then(Instant::now);
|
||||
if self.shift_down || self.select_remove_mode {
|
||||
self.tabs[i].scene.deselect_entities(&handles);
|
||||
@@ -5268,101 +5295,11 @@ was_selected={}",
|
||||
}
|
||||
|
||||
let crossing = box_crossing;
|
||||
let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
|
||||
let world_aabb = [a, iced::Point::new(a.x, p.y), p, iced::Point::new(p.x, a.y)]
|
||||
.map(|point| self.cursor_model_point(i, &edit_cam, point, bounds))
|
||||
.into_iter()
|
||||
.fold(
|
||||
[
|
||||
f64::INFINITY,
|
||||
f64::INFINITY,
|
||||
f64::NEG_INFINITY,
|
||||
f64::NEG_INFINITY,
|
||||
],
|
||||
|mut aabb, world| {
|
||||
aabb[0] = aabb[0].min(world.x);
|
||||
aabb[1] = aabb[1].min(world.y);
|
||||
aabb[2] = aabb[2].max(world.x);
|
||||
aabb[3] = aabb[3].max(world.y);
|
||||
aabb
|
||||
},
|
||||
);
|
||||
// This is the path a click-move-click window takes, as
|
||||
// opposed to a press-drag; it is the one a large selection
|
||||
// actually goes through.
|
||||
let t_sel = crate::perf::enabled().then(Instant::now);
|
||||
let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb(
|
||||
all_wires,
|
||||
world_aabb,
|
||||
[a.x.min(p.x), a.y.min(p.y), a.x.max(p.x), a.y.max(p.y)],
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
);
|
||||
let cand_ms = t_sel.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
|
||||
let t_hit = crate::perf::enabled().then(Instant::now);
|
||||
let candidate_handles = self.tabs[i]
|
||||
.scene
|
||||
.interaction_candidate_handles(&area_candidates);
|
||||
// `hit` covers five things, and which of them owns it has
|
||||
// never been separated: resolving candidate handles, the wire
|
||||
// box test, the two hatch tests and the two mesh tests.
|
||||
let m_handles = t_hit.map(|t| t.elapsed().as_secs_f64() * 1000.0);
|
||||
let mut handles: Vec<Handle> = scene::pick::hit_test::box_hit(
|
||||
a,
|
||||
p,
|
||||
crossing,
|
||||
&area_candidates,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
)
|
||||
.into_iter()
|
||||
.filter_map(|s| Scene::handle_from_wire_name(s))
|
||||
.collect();
|
||||
let m_wires = t_hit.map(|t| t.elapsed().as_secs_f64() * 1000.0);
|
||||
handles.extend(scene::pick::hit_test::box_hit_hatch(
|
||||
a,
|
||||
p,
|
||||
crossing,
|
||||
&self.tabs[i]
|
||||
.scene
|
||||
.visible_hatches_for_click(candidate_handles.as_ref()),
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
candidate_handles.as_ref(),
|
||||
));
|
||||
handles.extend(scene::pick::hit_test::box_hit_insert_hatch(
|
||||
a,
|
||||
p,
|
||||
crossing,
|
||||
self.tabs[i].scene.insert_hatches_for_click().as_ref(),
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
candidate_handles.as_ref(),
|
||||
));
|
||||
let m_hatch = t_hit.map(|t| t.elapsed().as_secs_f64() * 1000.0);
|
||||
handles.extend(self.tabs[i].scene.mesh_box_hit(
|
||||
a,
|
||||
p,
|
||||
crossing,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
candidate_handles.as_ref(),
|
||||
));
|
||||
handles.extend(self.tabs[i].scene.block_mesh_box_hit(
|
||||
a,
|
||||
p,
|
||||
crossing,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
candidate_handles.as_ref(),
|
||||
));
|
||||
let hit_ms = t_hit.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
|
||||
let (mut handles, timing) =
|
||||
self.box_selection_handles(i, &edit_cam, bounds, a, p, crossing);
|
||||
let (cand_ms, hit_ms) = (timing.candidates, timing.hit);
|
||||
let (m_handles, m_wires, m_hatch) =
|
||||
(Some(timing.handles), Some(timing.wires), Some(timing.hatch));
|
||||
let t_filter = crate::perf::enabled().then(Instant::now);
|
||||
// Selection filter: keep only allowed types.
|
||||
handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h));
|
||||
@@ -5805,8 +5742,9 @@ properties={:.1}ms picked={}",
|
||||
/// A wheel notch zooms; a trackpad's two fingers pan. Which one a delta
|
||||
/// means is `scroll_intent`'s call.
|
||||
pub(super) fn on_viewport_scroll(&mut self, delta: mouse::ScrollDelta) -> Task<Message> {
|
||||
match scroll_intent(delta) {
|
||||
match scroll_intent(delta, self.touchpad_pan, self.ctrl_down) {
|
||||
ScrollIntent::Zoom { notches } => {
|
||||
crate::perf::note_scroll_zoom();
|
||||
let mut s = notches * crate::app::settings::zoom_notch_steps(self.zoom_factor);
|
||||
if self.zoom_wheel_reversed {
|
||||
s = -s;
|
||||
@@ -6838,10 +6776,27 @@ mod scroll_intent_tests {
|
||||
use crate::scene::view::camera::Camera;
|
||||
use iced::mouse::ScrollDelta;
|
||||
|
||||
/// With the touchpad option, a scroll pans (wheel lines as pixels) and
|
||||
/// Ctrl+scroll — a pinch — still zooms.
|
||||
#[test]
|
||||
fn touchpad_pan_scrolls_pan_and_ctrl_zooms() {
|
||||
assert_eq!(
|
||||
scroll_intent(ScrollDelta::Lines { x: 0.5, y: -0.25 }, true, false),
|
||||
ScrollIntent::Pan {
|
||||
dx: 20.0,
|
||||
dy: -10.0
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
scroll_intent(ScrollDelta::Lines { x: 0.0, y: 1.0 }, true, true),
|
||||
ScrollIntent::Zoom { notches: 1.0 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_notches_zoom() {
|
||||
assert_eq!(
|
||||
scroll_intent(ScrollDelta::Lines { x: 0.0, y: 1.0 }),
|
||||
scroll_intent(ScrollDelta::Lines { x: 0.0, y: 1.0 }, false, false),
|
||||
ScrollIntent::Zoom { notches: 1.0 }
|
||||
);
|
||||
}
|
||||
@@ -6852,7 +6807,7 @@ mod scroll_intent_tests {
|
||||
#[test]
|
||||
fn two_finger_scroll_pans() {
|
||||
assert_eq!(
|
||||
scroll_intent(ScrollDelta::Pixels { x: 4.0, y: 6.0 }),
|
||||
scroll_intent(ScrollDelta::Pixels { x: 4.0, y: 6.0 }, false, false),
|
||||
ScrollIntent::Pan { dx: 4.0, dy: 6.0 }
|
||||
);
|
||||
}
|
||||
@@ -8329,3 +8284,15 @@ mod selection_preview_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a box selection spent its time, in milliseconds since each step's
|
||||
/// start (perf builds only; zero otherwise). `handles`, `wires` and `hatch`
|
||||
/// are cumulative within `hit`.
|
||||
#[derive(Default)]
|
||||
pub(in crate::app) struct BoxSelectTiming {
|
||||
pub candidates: f64,
|
||||
pub handles: f64,
|
||||
pub wires: f64,
|
||||
pub hatch: f64,
|
||||
pub hit: f64,
|
||||
}
|
||||
+11
-6
@@ -202,6 +202,7 @@ impl OpenCADStudio {
|
||||
crate::perf_record!("[perf] view {:>7.1}ms", elapsed_ms);
|
||||
}
|
||||
}
|
||||
crate::perf::note_view_finish();
|
||||
element
|
||||
}
|
||||
|
||||
@@ -929,11 +930,14 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
snap_angle_deg: self.snap_angle_deg,
|
||||
pick_pending: self.pending_pick_label().is_some(),
|
||||
// An infinite-line preview runs along the arms; drop them
|
||||
// so the preview stays visible (#1437).
|
||||
hide_arms: tab
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.is_some_and(|c| matches!(c.name(), "XLINE" | "RAY")),
|
||||
// so the preview stays visible (#1437). A command asking
|
||||
// for objects shows the pickbox alone, so picking reads
|
||||
// apart from both idle and point input (#1661).
|
||||
hide_arms: tab.active_cmd.as_ref().is_some_and(|c| {
|
||||
matches!(c.name(), "XLINE" | "RAY")
|
||||
|| c.needs_entity_pick()
|
||||
|| c.is_selection_gathering()
|
||||
}),
|
||||
snap_color: self.snap_marker_color,
|
||||
},
|
||||
crate::ui::overlay::SelectionVisualOptions {
|
||||
@@ -1898,7 +1902,8 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
// collecting its own input. A pending Lengthen / Radius / Arc Length
|
||||
// value is numeric input, not the prefix of a new command (for example,
|
||||
// `3` must not open the 3DALIGN / 3DARRAY suggestions).
|
||||
let allow_autocomplete = tab.active_cmd.is_none() && self.grip_pending.is_none();
|
||||
let allow_autocomplete = self.grip_pending.is_none()
|
||||
&& (tab.active_cmd.is_none() || self.command_line.has_contextual_suggestions());
|
||||
// Dynamic input captures keystrokes when its fields are showing,
|
||||
// so the command-line field must release focus / its on_input.
|
||||
// The MText preview also captures keystrokes (typing edits it), so the
|
||||
|
||||
+18
-8
@@ -293,6 +293,8 @@ impl OpenCADStudio {
|
||||
cliprompt_lines: self.cliprompt_lines,
|
||||
commandline_fade_ms: self.commandline_fade_ms,
|
||||
zoom_wheel_reversed: self.zoom_wheel_reversed,
|
||||
touchpad_pan: self.touchpad_pan,
|
||||
ui_scale_percent: self.ui_scale_percent,
|
||||
zoom_factor: self.zoom_factor,
|
||||
texteditmode: self.texteditmode,
|
||||
dimension_continue_mode: self.dimension_continue_mode,
|
||||
@@ -619,14 +621,22 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
linetypes.sort_by_key(|name| name.to_lowercase());
|
||||
sized_flow(ex, 1180, 560, |flow| {
|
||||
crate::ui::window::layer_state_manager::view_editor(
|
||||
state,
|
||||
&self.layer_state_edit_filter,
|
||||
self.layer_state_edit_color_open,
|
||||
linetypes.clone(),
|
||||
flow,
|
||||
)
|
||||
// Shared ownership: the build closure runs twice per frame
|
||||
// (INTRINSIC + FILL) and each row keeps its own copy, so a
|
||||
// borrow cannot work here — an Arc clone per row is a
|
||||
// refcount bump instead of N string clones (#21, cf. #34).
|
||||
let linetypes = std::sync::Arc::<[String]>::from(linetypes);
|
||||
sized_flow(ex, 1180, 560, {
|
||||
let linetypes = std::sync::Arc::clone(&linetypes);
|
||||
move |flow| {
|
||||
crate::ui::window::layer_state_manager::view_editor(
|
||||
state,
|
||||
&self.layer_state_edit_filter,
|
||||
self.layer_state_edit_color_open,
|
||||
std::sync::Arc::clone(&linetypes),
|
||||
flow,
|
||||
)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
automatic_flow(ex, |flow| {
|
||||
|
||||
@@ -27,7 +27,7 @@ fn fixture() -> (OpenCADStudio, Handle, ViewportFrame) {
|
||||
viewport.view_height = 1000.0;
|
||||
viewport.custom_scale = 0.1;
|
||||
viewport.view_direction = Vector3::UNIT_Z;
|
||||
viewport.status.is_on = true;
|
||||
viewport.turn_on();
|
||||
let viewport = scene.add_entity(EntityType::Viewport(viewport));
|
||||
let frame = scene.viewport_frame(viewport).unwrap();
|
||||
scene.document.header.dimension_associativity = 2;
|
||||
@@ -505,7 +505,7 @@ fn viewport_dimension_eligibility_is_shared_and_uses_clipping() {
|
||||
first.viewport
|
||||
);
|
||||
if let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(first.viewport) {
|
||||
vp.status.is_on = false;
|
||||
vp.turn_off();
|
||||
}
|
||||
assert!(scene.viewport_frames_at_paper_point(paper).is_empty());
|
||||
assert!(scene.dimension_pick_through_viewport(paper, 0.2).is_none());
|
||||
|
||||
Reference in new issue
Block a user