Merge main into Select-Color-Redesign

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

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

No files matched your search

+40
View File
@@ -66,6 +66,46 @@ impl OpenCADStudio {
None
}
/// ARRAYRECT / ARRAYPOLAR with Associative on: the objects become the
/// source of an associative array, in one undo step.
pub(super) fn handle_assoc_array(
&mut self,
mut handles: Vec<Handle>,
spec: crate::scene::assoc_array::AssocArraySpec,
) {
let i = self.active_tab;
handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle));
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
if handles.is_empty() {
return;
}
let label = self.history_label_from_active_cmd(i, "ARRAY");
let source_point = crate::app::helpers::entities_lower_left_by_bbox(
&self.tabs[i].scene.document,
&handles,
);
self.push_undo_snapshot(i, label.clone());
match crate::scene::assoc_array::create_from_spec(
&mut self.tabs[i].scene,
&handles,
source_point,
&spec,
) {
Ok(array) => {
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.select_entity(array, false);
self.command_line
.push_output(crate::tf!("{label}: associative array created.").as_ref());
}
Err(error) => self.command_line.push_error(&format!("{label}: {error}")),
}
self.tabs[i].dirty = true;
self.refresh_properties();
}
pub(super) fn handle_replace_many(
&mut self,
replacements: Vec<(Handle, Vec<codec::EntityType>)>,
+29
View File
@@ -990,6 +990,35 @@ impl OpenCADStudio {
pub(super) fn handle_attreq_needed(&mut self, block_name: String) -> Option<Task<Message>> {
let i = self.active_tab;
// When active space is a block editor session (BEDIT), refuse inserting
// any block that would create a circular nesting dependency.
if let Some(edit_handle) = self.tabs[i].scene.block_edit_block {
let container_name = self.tabs[i]
.scene
.document
.block_records
.iter()
.find(|br| br.handle == edit_handle)
.map(|br| br.name.clone());
if let Some(container) = container_name {
if crate::scene::block_service::would_create_cycle(
&self.tabs[i].scene.document,
&container,
&block_name,
) {
self.command_line.push_error(
&crate::scene::block_service::circular_dependency_error(
&container,
&block_name,
),
);
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
return None;
}
}
}
// Collect the full AttributeDefinitions owned by this block
// record so each created attribute keeps its geometry (#255).
let mut attdefs: Vec<codec::entities::AttributeDefinition> = {
+32 -3
View File
@@ -80,7 +80,7 @@ use crate::command::{CmdResult, SelectionEntity, StepInput};
use codec::Handle;
use iced::Task;
mod clipboard;
pub(super) mod clipboard;
mod constraint;
mod dim_edit;
mod entity_commit;
@@ -95,7 +95,8 @@ mod utilities;
mod viewport;
pub(crate) use clipboard::{
copy_to_clipboard_kernel, paste_entities_kernel, remap_ext_subtree_reference, PasteSpace,
copy_to_clipboard_kernel, paste_entities_kernel, remap_ext_subtree_reference, remap_object,
PasteSpace,
};
impl OpenCADStudio {
@@ -169,6 +170,7 @@ impl OpenCADStudio {
&result,
CmdResult::Relaunch(..)
| CmdResult::Dispatch(..)
| CmdResult::QSelectSample { .. }
| CmdResult::SolidEdgeBlend { .. }
| CmdResult::SolidSubtract { .. }
| CmdResult::SliceEntities { .. }
@@ -436,6 +438,13 @@ impl OpenCADStudio {
// runtime or messages such as a chosen render mode are dropped.
let mut dispatched = Task::none();
match result {
CmdResult::QSelectSample {
state,
tab_id,
handle,
} => {
self.finish_qselect_sample(*state, tab_id, handle);
}
CmdResult::OpenAutoConstrainSettings => {
self.handle_open_auto_constrain_settings();
}
@@ -598,6 +607,9 @@ impl OpenCADStudio {
return task;
}
}
CmdResult::AssocArray { handles, spec } => {
self.handle_assoc_array(handles, spec);
}
CmdResult::ReplaceMany(replacements, additions) => {
if let Some(task) = self.handle_replace_many(replacements, additions) {
return task;
@@ -874,6 +886,21 @@ impl OpenCADStudio {
CmdResult::ZoomToWindow { p1, p2 } => {
self.handle_zoom_to_window(p1, p2);
}
CmdResult::Show { p1, p2 } => {
let i = self.active_tab;
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.zoom_to_window(p1.as_vec3(), p2.as_vec3());
}
CmdResult::CommitAndZoom { entity, p1, p2 } => {
self.handle_commit_entities_and_exit(vec![entity], false, false);
self.handle_zoom_to_window(p1, p2);
}
CmdResult::ScaleViewports { handles, factor } => {
self.handle_scale_viewports(handles, factor);
}
CmdResult::TwistView(twist) => {
self.handle_twist_view(twist);
}
CmdResult::Measurement(msg) => {
self.handle_measurement(msg);
}
@@ -1284,7 +1311,9 @@ impl OpenCADStudio {
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent();
self.command_line.push_output(&msg);
if !msg.is_empty() {
self.command_line.push_output(&msg);
}
}
fn handle_report_measurement(&mut self, msg: String) {
+20 -34
View File
@@ -1284,12 +1284,9 @@ mod bedit_characterization_tests {
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.
/// Every entry point must refuse while REFEDIT is active.
#[test]
fn documenting_bedit_begin_bypasses_refedit_guard() {
fn locked_bedit_begin_refuses_active_refedit() {
let mut app = OpenCADStudio::new_for_test();
let (insert, _) = setup_block_and_insert(&mut app);
let tab = app.active_tab;
@@ -1297,8 +1294,8 @@ mod bedit_characterization_tests {
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"
app.tabs[tab].active_block_edit.is_none(),
"begin must refuse while REFEDIT is active"
);
}
@@ -1323,12 +1320,9 @@ mod bedit_characterization_tests {
// ---- 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.
/// Discard removes local before-images before drawing undo resumes.
#[test]
fn documenting_modify_discard_then_undo_probes_stale_before_image() {
fn locked_modify_discard_then_undo_does_not_restore_local_edits() {
let mut app = OpenCADStudio::new_for_test();
let (insert, members) = setup_block_and_insert(&mut app);
let line = members[0];
@@ -1367,10 +1361,9 @@ mod bedit_characterization_tests {
);
}
/// Documenting: undo during a session crosses the entry boundary. A
/// single undo after an in-session edit may revert the BEDIT_BEGIN step.
/// Local undo restores both the entity store and block membership.
#[test]
fn documenting_undo_inside_session_crosses_entry_boundary() {
fn locked_session_undo_restores_membership_without_crossing_entry() {
let mut app = OpenCADStudio::new_for_test();
let (insert, _) = setup_block_and_insert(&mut app);
begin_bedit(&mut app, insert);
@@ -1381,38 +1374,33 @@ mod bedit_characterization_tests {
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"
1,
"block membership must match the undone entity store"
);
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_eq!(dangling, 0, "every member must have an 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.
/// Opening starts an empty local history; undo cannot cross entry.
#[test]
fn documenting_undo_across_entry_leaves_session_open() {
fn locked_session_entry_has_no_undo_step() {
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");
assert_eq!(depth_after_begin, 0, "opening must not record an undo step");
app.undo_steps(1);
assert!(
@@ -1421,10 +1409,9 @@ mod bedit_characterization_tests {
);
}
/// Documenting: BEDIT on a block while another block is open nests a
/// second session. Records the resulting stack.
/// Switching a clean block replaces the single session.
#[test]
fn documenting_nested_bedit_opens_second_session() {
fn locked_clean_switch_replaces_the_session() {
let mut app = OpenCADStudio::new_for_test();
let (insert, _) = setup_block_and_insert(&mut app);
let tab = app.active_tab;
@@ -1448,15 +1435,14 @@ mod bedit_characterization_tests {
begin_bedit(&mut app, other_insert);
assert_eq!(
app.tabs[tab].block_edits.len(),
2,
"documenting: a second BEDIT pushes a nested session"
1,
"a drawing has only one block-editor session"
);
}
/// Documenting: BEDIT_BEGIN pushes an undo entry, and BEDIT_SAVE and
/// BEDIT_DISCARD push none. Phase 1 may change this.
/// No-change Save and Discard leave the drawing history untouched.
#[test]
fn documenting_save_and_discard_push_no_undo_entry() {
fn locked_clean_save_and_discard_leave_history_unchanged() {
let mut app = OpenCADStudio::new_for_test();
let (insert, _) = setup_block_and_insert(&mut app);
begin_bedit(&mut app, insert);
+3
View File
@@ -24,6 +24,9 @@ impl OpenCADStudio {
// component so each member keeps its own dimensions. Other
// transforms retain their selected-object behavior.
let pending = self.begin_undo(i, label, handles.len(), true);
let handles = self.tabs[i]
.scene
.transform_picked_segments(&handles, &transform);
self.tabs[i].scene.transform_entities(&handles, &transform);
self.apply_inferred_constraints(i, &handles);
self.tabs[i].dirty = true;
+39 -1
View File
@@ -98,6 +98,7 @@ impl OpenCADStudio {
self.otrack_cross = None;
self.otrack_kind = None;
self.axis_lock_dir = None;
self.angle_override = None;
self.dyn_user_reshaped = false;
self.dyn_coord_absolute = false;
self.grip_hover = None;
@@ -506,6 +507,32 @@ impl OpenCADStudio {
StepInput::SelectionComplete(kept)
}
/// Keeps only the kinds of object the active command takes, dropping
/// the rest of a completed selection silently.
fn filter_command_entity_selection(&mut self, input: StepInput) -> StepInput {
let StepInput::SelectionComplete(handles) = input else {
return input;
};
let i = self.active_tab;
let Some(keep) = self.tabs[i]
.active_cmd
.as_ref()
.and_then(|command| command.selection_entity_filter())
else {
return StepInput::SelectionComplete(handles);
};
let (kept, dropped): (Vec<Handle>, Vec<Handle>) = handles
.into_iter()
.partition(|h| self.tabs[i].scene.document.get_entity(*h).is_some_and(keep));
for handle in &dropped {
self.tabs[i].scene.deselect_entity(*handle);
}
if !dropped.is_empty() {
self.refresh_properties();
}
StepInput::SelectionComplete(kept)
}
/// Drops locked point clouds from a completed selection when the active
/// command would move them, saying which were locked and how many went.
fn filter_locked_point_cloud_selection(&mut self, input: StepInput) -> StepInput {
@@ -561,6 +588,7 @@ impl OpenCADStudio {
let input = self.filter_associative_dimension_selection(input);
let input = self.filter_block_reference_selection(input);
let input = self.filter_locked_point_cloud_selection(input);
let input = self.filter_command_entity_selection(input);
// XCLIP / CLIP learn which chosen references carry a clip already.
{
let i = self.active_tab;
@@ -595,6 +623,7 @@ impl OpenCADStudio {
if !self.command_point_allowed(i, *point) {
return (Task::none(), true);
}
self.angle_override = None;
// Typed / dynamic-input / headless points carry no snap, but the
// accepted-snap list must stay index-parallel with the points the
// command collects. Interactive picks record themselves in
@@ -1130,6 +1159,9 @@ impl OpenCADStudio {
self.apply_snap_override(kind);
return Task::none();
}
if self.try_angle_override(token) {
return Task::none();
}
if let Some((coord, kind)) = crate::app::helpers::parse_coord(token) {
// Match the GUI command line: typed coordinates are in the active
// UCS (relative offsets are rotated by the UCS axes), so a multi-
@@ -1161,7 +1193,13 @@ impl OpenCADStudio {
// bare number is the step's own value, not a distance — ROTATE's
// angle, SCALE's factor, CIRCLE's radius. Only a token no step
// claims becomes direct distance entry, measured along the cursor
// direction from the command's anchor.
// direction from the command's anchor. Under a typed `<angle`
// the number is the distance along it first.
if self.angle_override.is_some() {
if let Some(distance) = self.try_direct_distance_entry(token) {
return distance;
}
}
let (task, consumed) = self.feed_command_consumed(StepInput::Text(token.to_string()));
if consumed {
return task;
+47
View File
@@ -447,6 +447,53 @@ impl OpenCADStudio {
.push_output(crate::t!("Zoom Window").as_ref());
}
/// MVSETUP Scale viewports: each picked layout viewport shows the model
/// at `factor` paper units per model unit. One undo step.
pub(in crate::app) fn handle_scale_viewports(&mut self, handles: Vec<Handle>, factor: f64) {
let i = self.active_tab;
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
let handles: Vec<Handle> = handles
.into_iter()
.filter(|handle| {
matches!(
self.tabs[i].scene.document.get_entity(*handle),
Some(codec::EntityType::Viewport(viewport)) if viewport.id > 1
)
})
.collect();
if handles.is_empty() {
return;
}
self.push_undo_snapshot(i, "MVSETUP");
for handle in handles {
self.tabs[i]
.scene
.set_viewport_scale_factor_for(handle, factor);
}
self.tabs[i].dirty = true;
}
/// Turn the active view about its line of sight so the drawing shows
/// `twist` radians counter-clockwise from its untwisted orientation.
pub(in crate::app) fn handle_twist_view(&mut self, twist: f64) {
let i = self.active_tab;
self.tabs[i].scene.remember_current_view();
let set = |camera: &mut crate::scene::view::camera::Camera| {
// The drawing turns one way when the camera rolls the other.
camera.roll_by(-twist as f32 - camera.roll());
};
if self.tabs[i].scene.active_viewport.is_some() {
self.tabs[i].scene.mutate_active_viewport_camera(set);
} else {
set(&mut self.tabs[i].scene.camera.borrow_mut());
}
self.tabs[i].scene.camera_generation += 1;
if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) {
self.command_line.push_info(&prompt);
}
}
pub(super) fn handle_set_plot_window(&mut self, p1: glam::DVec3, p2: glam::DVec3) {
let i = self.active_tab;
let layout_name = self.tabs[i].scene.current_layout.clone();