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:
Claude committed 2026-10-11 18:44:00 +00:00
commit 2fca3fd18d
136 files changed
+12408 -949

No files matched your search

+2 -1
View File
@@ -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
View File
@@ -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 {
+124 -11
View File
@@ -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"
);
}
}
+2 -1
View File
@@ -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;
}
+25 -2
View File
@@ -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 {
+359 -8
View File
@@ -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
View File
@@ -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 {
+76
View File
@@ -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!(
+96
View File
@@ -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]
+1
View File
@@ -706,6 +706,7 @@ inventory::submit!(crate::command::CommandRegistration {
"PDFOSNAP",
"UOSNAP",
"FIELDDISPLAY",
"SURFACEASSOCIATIVITY",
"FIELDEVAL",
"PDFIMPORTMODE",
"PDFIMPORTFILTER",
+97 -19
View File
@@ -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(&lt.name))
&& !used_linetypes.contains(&lt.name)
&& !used_linetypes.contains(&lt.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 &lt_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" => {
+1 -1
View File
@@ -814,7 +814,7 @@ impl OpenCADStudio {
vp.view_height,
vp.height,
),
vp.status.is_on,
vp.is_on(),
vp.status.locked,
))
} else {
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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};
+15
View File
@@ -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,
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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)]
+1
View File
@@ -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
View File
@@ -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);
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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| {
+2 -2
View File
@@ -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());