forked from 1b-agent/opencadstudio-upstream
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:
commit
ad718def3f
124 files changed
+9866
-692
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user