Merge remote-tracking branch 'origin/main' into merge-test/main-into-constraints

# Conflicts:
#	src/app/command_driver.rs
#	src/command.rs
#	src/ui/ribbon/mod.rs
#	src/ui/window/options.rs
This commit is contained in:
Felix Riestra committed 2026-09-10 23:34:50 +02:00
commit ad718def3f
124 files changed
+9866 -692

No files matched your search

+84 -6
View File
@@ -502,13 +502,9 @@ impl OpenCADStudio {
self.command_line.push_output(crate::t!("REDRAWALL: viewports refreshed.").as_ref());
return Some(Task::none());
}
// REGEN — full model regeneration (bump_geometry: geometry_epoch AND
// block_epoch; C4). No undo, no DB mutation, so do NOT touch
// self.tabs[i].dirty — a newly opened drawing must not become
// "modified" merely because tessellation caches were invalidated (C7).
// REGENALL is functionally identical (C5).
// Rebuild tessellation caches without modifying the document.
"REGEN" | "REGENALL" => {
self.tabs[i].scene.bump_geometry();
self.tabs[i].scene.populate_meshes_from_document();
self.command_line.push_output(crate::t!("REGEN: regenerated model.").as_ref());
return Some(Task::none());
}
@@ -1892,4 +1888,86 @@ mod tests {
let _ = app.run_command_line("REGENALL");
assert!(!app.tabs[i].dirty, "REGENALL must not dirty the document either");
}
#[test]
fn regen_rebuilds_the_mesh_map_rather_than_only_bumping_the_epoch() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
let stale = acadrust::Handle::new(0xDEAD);
app.tabs[i].scene.meshes.insert(stale, stale_mesh());
let epoch_before = app.tabs[i].scene.geometry_epoch;
let _ = app.run_command_line("REGEN");
assert!(
!app.tabs[i].scene.meshes.contains_key(&stale),
"REGEN left a stale mesh",
);
assert_ne!(
app.tabs[i].scene.geometry_epoch, epoch_before,
"REGEN did not bump the geometry epoch",
);
}
#[test]
fn the_isolines_slider_rebuilds_once_on_release_and_not_while_dragging() {
use crate::app::Message;
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
let seed = |app: &mut OpenCADStudio, i: usize| {
let stale = acadrust::Handle::new(0xBEEF);
app.tabs[i].scene.meshes.insert(stale, stale_mesh());
stale
};
let stale = seed(&mut app, i);
for value in [4i16, 3, 2, 1, 0] {
let _ = app.update(Message::IsolinesChanged(value));
}
assert!(
app.tabs[i].scene.meshes.contains_key(&stale),
"the drag itself must not rebuild the meshes",
);
assert_eq!(app.tabs[i].scene.document.header.isolines, 0);
let _ = app.update(Message::IsolinesReleased);
assert!(
!app.tabs[i].scene.meshes.contains_key(&stale),
"releasing after a change rebuilds them",
);
let stale = seed(&mut app, i);
let _ = app.update(Message::IsolinesChanged(0));
let _ = app.update(Message::IsolinesReleased);
assert!(
app.tabs[i].scene.meshes.contains_key(&stale),
"releasing without a change must rebuild nothing",
);
}
fn stale_mesh() -> crate::scene::model::mesh_model::MeshLodSet {
crate::scene::model::mesh_model::MeshLodSet {
lods: Vec::new(),
material: None,
face_materials: Default::default(),
visual_style: None,
complete: true,
edge_verts: Vec::new(),
edge_verts_low: Vec::new(),
curved_gens: Vec::new(),
metrics: Default::default(),
world_aabb: [0.0; 4],
z_aabb: [0.0; 2],
instance_source: None,
instance_transform: None,
instance_handle: None,
instance_color: None,
instance_aabb: None,
}
}
}
+71 -7
View File
@@ -62,7 +62,7 @@ impl OpenCADStudio {
return Some(Task::done(Message::ImagePick));
}
"REVCLOUD" => {
"REVCLOUD" | "REVCLOUD_RECTANGULAR" | "REVCLOUD_POLYGONAL" | "REVCLOUD_FREEHAND" => {
use crate::modules::draw::draw::revcloud::RevCloudCommand;
let view_height = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0;
let default_arc_length = (view_height * 0.0125).max(1.0e-6);
@@ -72,9 +72,12 @@ impl OpenCADStudio {
.entities()
.map(|entity| (entity.common().handle, entity.clone()))
.collect();
let cmd = RevCloudCommand::new(default_arc_length, sources);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
let mut command = RevCloudCommand::new(default_arc_length, sources);
if let Some(mode) = cmd.strip_prefix("REVCLOUD_") {
command.on_text_input(mode);
}
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"ATTDEF" => {
@@ -872,9 +875,13 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
"SPLINE" => {
"SPLINE" | "SPLINECV" => {
use crate::modules::draw::draw::spline::SplineCommand;
let new_cmd = SplineCommand::new();
let new_cmd = if cmd == "SPLINECV" {
SplineCommand::control_vertices()
} else {
SplineCommand::new()
};
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
@@ -1299,10 +1306,18 @@ impl OpenCADStudio {
// REGION — convert selected closed boundaries (closed polylines /
// circles) into Region entities (one wire loop each).
"REGION" | "REG" => {
if self.tabs[i].scene.selected_entities().is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
let command = SelectObjectsCommand::new("REGION");
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
return Some(iced::Task::none());
}
use acadrust::entities::Region;
use acadrust::types::Vector3;
let mut regions = Vec::new();
for (_, e) in self.tabs[i].scene.selected_entities().iter() {
let mut sources = Vec::new();
for (handle, e) in self.tabs[i].scene.selected_entities().iter() {
let supported = matches!(
e,
acadrust::EntityType::LwPolyline(pl)
@@ -1325,6 +1340,7 @@ impl OpenCADStudio {
);
region.common.layer = self.tabs[i].active_layer.clone();
regions.push((region, body));
sources.push(*handle);
}
}
if regions.is_empty() {
@@ -1343,6 +1359,10 @@ impl OpenCADStudio {
}
created.push(handle);
}
if self.delete_objects != 0 {
self.tabs[i].scene.erase_entities(&sources);
self.refresh_properties();
}
self.tabs[i].dirty = true;
self.command_line
.push_output(crate::tf!("REGION: created {count} region(s).").as_ref());
@@ -1719,3 +1739,47 @@ impl OpenCADStudio {
Some(self.finish_dispatch(cmd))
}
}
#[cfg(test)]
mod region_tests {
use crate::app::OpenCADStudio;
#[test]
fn region_respects_delobj_and_preserves_unconverted_sources() {
for delete_sources in [false, true] {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 3"}"#);
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#);
let i = app.active_tab;
let sources: Vec<_> = app.tabs[i].scene.document.entities()
.map(|entity| {
(entity.common().handle, matches!(entity, acadrust::EntityType::Circle(_)))
})
.collect();
assert_eq!(sources.len(), 2);
let handles: Vec<_> = sources.iter().map(|(handle, _)| *handle).collect();
app.tabs[i].scene.select_entities(&handles);
app.delete_objects = i16::from(delete_sources);
let _ = app.dispatch_command("REGION");
for (handle, is_circle) in &sources {
assert_eq!(
app.tabs[i].scene.document.get_entity(*handle).is_some(),
!delete_sources || !is_circle,
);
}
let region_count = app.tabs[i].scene.document.entities()
.filter(|entity| matches!(entity, acadrust::EntityType::Region(_)))
.count();
assert_eq!(region_count, 1);
app.automation_op(r#"{"op":"undo"}"#);
for (handle, _) in &sources {
assert!(app.tabs[i].scene.document.get_entity(*handle).is_some());
}
assert_eq!(app.tabs[i].scene.document.entities().count(), 2);
}
}
}
+44 -1
View File
@@ -75,6 +75,12 @@ impl OpenCADStudio {
.dispatch_families(cmd, i)
.unwrap_or_else(Task::none);
}
// A new command abandons any grip edit and its numeric input.
let had_pending_grip_input = self.grip_pending.take().is_some();
let had_active_grip = self.cancel_active_grip_edit();
if had_pending_grip_input || had_active_grip {
self.command_line.input.clear();
}
// Starting a command closes any open ribbon dropdown (e.g. a style
// combo left open) so it does not stay stuck behind the new tool.
self.ribbon.close_dropdown();
@@ -553,6 +559,11 @@ inventory::submit!(crate::command::CommandRegistration {
"GRIPHOT",
"GRIPHOVER",
"GRIPOBJLIMIT",
// Dispatched all along, but absent from the registry, so command-line
// completion never offered them.
"ISAVEBAK",
"SAVETIME",
"FILEASSOC",
// Reset selected entities' overrides to follow their layer.
"SETBYLAYER",
// Remove duplicate objects; set drawing base point; audit integrity;
@@ -702,7 +713,10 @@ inventory::submit!(crate::command::CommandRegistration {
#[cfg(test)]
mod marquee_cancel_tests {
use crate::app::OpenCADStudio;
use crate::app::{GripPendingValue, OpenCADStudio};
use crate::scene::model::object::GripMenuAction;
use crate::scene::pick::grip::GripEdit;
use acadrust::Handle;
use iced::time::Instant;
fn fresh() -> OpenCADStudio {
@@ -816,4 +830,33 @@ mod marquee_cancel_tests {
assert!(sel.box_crossing);
assert!(sel.box_crossing_locked);
}
#[test]
fn starting_a_command_cancels_a_grip_value_edit() {
let mut app = fresh();
let i = app.active_tab;
let handle = Handle::new(42);
app.tabs[i].active_grip = Some(GripEdit::radius(
handle,
1,
glam::DVec3::new(2.0, 0.0, 0.0),
));
app.grip_pending = Some(GripPendingValue {
handle,
grip_id: 1,
action: GripMenuAction::Radius,
label: "New radius",
});
app.command_line.input = "5".to_string();
let _ = app.dispatch_command("LINE");
assert!(app.tabs[i].active_grip.is_none());
assert!(app.grip_pending.is_none());
assert!(app.command_line.input.is_empty());
assert_eq!(
app.tabs[i].active_cmd.as_deref().map(|cmd| cmd.name()),
Some("LINE")
);
}
}
+88 -13
View File
@@ -1159,27 +1159,45 @@ impl OpenCADStudio {
"0" | "OFF" | "FALSE" => Some(false),
_ => None,
};
let current_delete_objects = self.delete_objects;
let requested_delete_objects = (name == "DELOBJ")
.then(|| value.as_deref()?.parse::<i16>().ok())
.flatten()
.filter(|value| (0..=3).contains(value));
let outcome: Result<(String, bool), String> = {
let h = &mut self.tabs[i].scene.document.header;
match name.as_str() {
// The standalone LTSCALE and CELTSCALE commands
// require a positive value; reaching the same
// variable through SETVAR used to accept zero or a
// negative one, which degenerates every dash
// pattern in the drawing.
"LTSCALE" => match &value {
Some(v) => v
.parse::<f64>()
.ok()
.filter(|x| x.is_finite() && *x > 0.0)
.map(|x| {
h.linetype_scale = x;
(format!("LTSCALE = {x}"), true)
})
.map_err(|_| "SETVAR: numeric value required.".into()),
.ok_or_else(|| {
"SETVAR: positive numeric value required.".into()
}),
None => Ok((format!("LTSCALE = {}", h.linetype_scale), false)),
},
"CELTSCALE" => match &value {
Some(v) => v
.parse::<f64>()
.ok()
.filter(|x| x.is_finite() && *x > 0.0)
.map(|x| {
h.current_entity_linetype_scale = x;
(format!("CELTSCALE = {x}"), true)
})
.map_err(|_| "SETVAR: numeric value required.".into()),
.ok_or_else(|| {
"SETVAR: positive numeric value required.".into()
}),
None => Ok((
format!("CELTSCALE = {}", h.current_entity_linetype_scale),
false,
@@ -1633,15 +1651,18 @@ impl OpenCADStudio {
}
},
"DELOBJ" => match &value {
Some(v) => parse_bool(v)
.map(|b| {
h.delete_objects = b;
(format!("DELOBJ = {}", b as i32), true)
Some(v) => v
.parse::<i16>()
.ok()
.filter(|value| (0..=3).contains(value))
.map(|value| {
(format!("DELOBJ = {value}"), true)
})
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
None => {
Ok((format!("DELOBJ = {}", h.delete_objects as i32), false))
}
.ok_or_else(|| "SETVAR: integer from 0 to 3 required.".into()),
None => Ok((
format!("DELOBJ = {current_delete_objects}"),
false,
)),
},
"PLINEGEN" => match &value {
Some(v) => parse_bool(v)
@@ -2119,6 +2140,9 @@ impl OpenCADStudio {
};
match outcome {
Ok((msg, changed)) => {
if let Some(value) = requested_delete_objects {
self.delete_objects = value;
}
if changed {
if matches!(
name.as_str(),
@@ -2144,6 +2168,13 @@ impl OpenCADStudio {
| "GRIPHOT"
| "GRIPHOVER"
| "GRIPOBJLIMIT"
| "DELOBJ"
// App-level, not part of the drawing:
// it lives in a process global and is
// snapshotted into the settings file.
// Without this it marked the drawing
// modified instead of persisting.
| "TEXTFILL"
) {
self.persist_settings_if_changed();
} else {
@@ -2172,9 +2203,7 @@ impl OpenCADStudio {
// TEXTFILL reset the glyph atlas; re-tessellate so text picks
// up the re-baked filled / hollow tiles.
if name == "TEXTFILL" {
self.tabs[i]
.scene
.invalidate_text_geometry_dependencies();
self.invalidate_text_everywhere();
}
// LTSCALE scales the dash pattern baked into every wire, and
// PDMODE / PDSIZE decide the point glyph built at tessellation
@@ -3120,4 +3149,50 @@ mod tests {
assert!(app.tabs[app.active_tab].active_cmd.is_none());
assert_eq!(app.commandline_fade_ms, 5000);
}
#[test]
fn delobj_accepts_values_zero_through_three() {
let mut app = fresh_app();
for value in 0..=3 {
let _ = app.run_command_line(&format!("SETVAR DELOBJ {value}"));
assert_eq!(app.delete_objects, value);
}
let _ = app.run_command_line("SETVAR DELOBJ 4");
assert_eq!(app.delete_objects, 3);
}
}
#[cfg(test)]
mod scale_validation_tests {
use crate::app::OpenCADStudio;
/// A linetype scale of zero or less degenerates every dash pattern in the
/// drawing, which is why the standalone commands reject it. Reaching the
/// same variable through SETVAR used to accept it, so which door you came
/// through decided whether the drawing could be broken.
#[test]
fn both_doors_to_ltscale_refuse_a_non_positive_value() {
for name in ["LTSCALE", "CELTSCALE"] {
for entry in [format!("{name} -5"), format!("SETVAR {name} -5")] {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
let before = if name == "LTSCALE" {
app.tabs[i].scene.document.header.linetype_scale
} else {
app.tabs[i].scene.document.header.current_entity_linetype_scale
};
let _ = app.run_command_line(&entry);
let after = if name == "LTSCALE" {
app.tabs[i].scene.document.header.linetype_scale
} else {
app.tabs[i].scene.document.header.current_entity_linetype_scale
};
assert_eq!(before, after, "{entry} must be refused");
}
}
}
}