Merge remote-tracking branch 'origin/main' into plugin/host-model-api

# Conflicts:
#	crates/ocs_plugin_api/src/process/v4.rs
#	src/app/update/mod.rs
This commit is contained in:
Felix Riestra committed 2026-09-19 23:58:01 +02:00
commit 8648225ac5
309 files changed
+81166 -4157

No files matched your search

+1198 -62
View File
File diff suppressed because it is too large. Load diff
+48 -6
View File
@@ -869,6 +869,24 @@ impl OpenCADStudio {
"PRINTALL" => {
return Some(Task::done(Message::PrintAllOpen));
}
// PRINTERS — what the system reports about printing, on the
// command line: the default printer, every printer it lists (or
// why it could not), and how a plot will reach them. The report
// a user pastes into a bug report instead of a screenshot.
"PRINTERS" => {
for line in crate::io::print_to_printer::printer_report() {
self.command_line.push_output(&line);
}
}
// PRINTERS <name> — what that printer reports about its sheets
// and printable area (asks the driver; may take a moment for an
// offline network queue).
cmd if cmd.starts_with("PRINTERS ") => {
let name = cmd["PRINTERS ".len()..].trim();
for line in crate::io::print_to_printer::printer_media_report(name) {
self.command_line.push_output(&line);
}
}
"EXPORT" | "EXPORTPDF" => {
return Some(Task::done(Message::PlotExport));
}
@@ -1163,7 +1181,7 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(c));
}
cmd if cmd.starts_with("HYPERLINK ") => {
use acadrust::xdata::{ExtendedDataRecord, XDataValue};
use acadrust::xdata::XDataValue;
let url = cmd.strip_prefix("HYPERLINK").unwrap_or("").trim().to_string();
if url.is_empty() {
self.command_line.push_info(crate::t!("Usage: HYPERLINK <url> (select objects first)").as_ref());
@@ -1183,15 +1201,19 @@ impl OpenCADStudio {
self.push_undo_snapshot(i, "HYPERLINK");
let mut n = 0usize;
for h in &handles {
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) {
let xd = &mut e.common_mut().extended_data;
let mut rec = ExtendedDataRecord::new("PE_URL");
rec.add_value(XDataValue::String(url.clone()));
xd.add_record(rec);
if self.tabs[i].scene.document.get_entity(*h).is_some() {
crate::scene::view::dispatch::set_entity_xdata(
&mut self.tabs[i].scene.document,
*h,
"PE_URL",
Some(vec![XDataValue::String(url.clone())]),
);
n += 1;
}
}
self.invalidate_property_targets(i, &handles);
self.tabs[i].dirty = true;
self.refresh_properties();
self.command_line
.push_output(crate::tf!("HYPERLINK: attached to {n} object(s).").as_ref());
return Some(Task::none());
@@ -2094,3 +2116,23 @@ mod tests {
}
}
}
#[cfg(test)]
mod printers_command_tests {
use crate::app::OpenCADStudio;
#[test]
fn printers_reports_the_default_and_the_list() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let before = app.command_line.history.len();
let _ = app.run_command_line("PRINTERS");
let lines: Vec<String> = app.command_line.history[before..]
.iter()
.map(|line| line.text.clone())
.collect();
// One line for the default printer, at least one for the list (a
// count and names, "none", or the system's error).
assert!(lines.len() >= 2, "{lines:?}");
}
}
+346 -81
View File
@@ -57,6 +57,10 @@ impl OpenCADStudio {
return Some(Task::done(Message::ImagePick));
}
cmd if cmd == "IMAGEEMBED" => {
return Some(Task::done(Message::ImageEmbedPick));
}
"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;
@@ -307,8 +311,8 @@ impl OpenCADStudio {
}
"ARC" => {
use crate::modules::draw::draw::arc::Arc3PCommand;
let new_cmd = Arc3PCommand::new();
use crate::modules::draw::draw::arc::ArcCommand;
let new_cmd = ArcCommand::new();
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
@@ -319,8 +323,8 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
"ARC_CSE" => {
use crate::modules::draw::draw::arc::ArcCommand;
let new_cmd = ArcCommand::new();
use crate::modules::draw::draw::arc::ArcCSECommand;
let new_cmd = ArcCSECommand::new();
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
@@ -1012,6 +1016,26 @@ impl OpenCADStudio {
}
// ── Persistent constraints ────────────────────────────────────
"CONSTRAINTBAR" | "CONSTRAINTBAR_OPTIONS" => {
use crate::modules::parametric::ConstraintBarOptionCommand;
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() && cmd == "CONSTRAINTBAR" {
use crate::modules::draw::select::SelectObjectsCommand;
let command =
SelectObjectsCommand::routed("CONSTRAINTBAR", "CONSTRAINTBAR_OPTIONS");
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
} else if handles.is_empty() {
self.command_line.push_output("No objects selected.");
} else {
let command = ConstraintBarOptionCommand::new(handles);
self.command_line.push_info(&command.prompt());
self.command_line.set_step_options(command.options());
self.tabs[i].active_cmd = Some(Box::new(command));
}
}
"GCSHOW" | "GCHIDE" | "DCSHOW" | "DCHIDE" => {
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
@@ -1044,6 +1068,24 @@ impl OpenCADStudio {
.push_output(format!("{} constraint indicator(s) reset.", count).as_str());
}
"CONSTRAINTBAR_RESET" => {
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
self.command_line.push_output("No objects selected.");
} else {
let scope = self.tabs[i].current_parametric_scope();
let count = self.tabs[i].scene.set_parametric_constraint_visibility(
scope,
Some(&handles),
false,
true,
);
self.command_line.push_output(
format!("{} constraint bar(s) reset.", count).as_str(),
);
}
}
"GCSHOWALL" | "GCHIDEALL" | "DCSHOWALL" | "DCHIDEALL" => {
let scope = self.tabs[i].current_parametric_scope();
let dimensional = cmd.starts_with("DC");
@@ -1165,10 +1207,15 @@ impl OpenCADStudio {
.record_undo_parametric_constraints_before(scope, before);
let count = inferred.len();
for (kind, refs) in inferred {
self.tabs[i]
let id = self.tabs[i]
.scene
.parametric_constraint_set_mut(scope)
.add(kind, refs, None);
self.tabs[i].scene.note_parametric_constraint_applied(
scope,
id,
self.constraint_bar_display,
);
}
let changes: Vec<_> = handles
.iter()
@@ -1197,17 +1244,38 @@ impl OpenCADStudio {
}
}
"SMOOTHCONSTRAINT" => {
"GCSMOOTH" => {
use crate::modules::parametric::SmoothConstraintCommand;
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
let sel = SelectObjectsCommand::new(cmd);
self.command_line.push_info(&sel.prompt());
self.tabs[i].active_cmd = Some(Box::new(sel));
let command = SmoothConstraintCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
} else if handles.len() != 2 {
self.tabs[i].scene.deselect_all();
self.command_line.push_error(
"Smooth requires an open spline first and a target curve second.",
);
let command = SmoothConstraintCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
} else {
let Some(refs) = self.tabs[i].scene.smooth_constraint_refs(&handles) else {
self.command_line
.push_output("Select one open spline and one open target curve.");
let first = self.tabs[i].scene.document.get_entity(handles[0]);
let second = self.tabs[i].scene.document.get_entity(handles[1]);
let refs = first.zip(second).and_then(|(first, second)| {
SmoothConstraintCommand::preselected_refs(
(first, handles[0]),
(second, handles[1]),
)
});
let Some(refs) = refs else {
self.tabs[i].scene.deselect_all();
self.command_line.push_error(
"Smooth requires an open spline first and a line, arc, polyline segment or open spline second.",
);
let command = SmoothConstraintCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
return None;
};
use crate::command::CmdResult;
@@ -1281,11 +1349,16 @@ impl OpenCADStudio {
.scene
.record_undo_parametric_constraints_before(scope, before);
for (_, kind, refs, value) in conversions {
self.tabs[i].scene.parametric_constraint_set_mut(scope).add(
let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add(
kind,
refs,
Some(value),
);
self.tabs[i].scene.note_parametric_constraint_applied(
scope,
id,
self.constraint_bar_display,
);
}
self.tabs[i].scene.erase_entities(&dimensions);
let changes: Vec<_> = touched
@@ -1304,33 +1377,101 @@ impl OpenCADStudio {
}
}
"HCONSTRAINT" | "VCONSTRAINT" | "FXCONSTRAINT" => {
"GCHORIZONTAL" | "VCONSTRAINT" | "GCVERTICAL" => {
use crate::command::{CmdResult, HorizontalConstraintSelection};
use crate::modules::parametric::HorizontalConstraintCommand;
use crate::scene::parametric_constraints::ConstraintKind;
// Vertical is Horizontal mirrored onto the working plane's Y
// axis: same picks, same 2Points flow, same reference types.
let vertical = cmd != "GCHORIZONTAL";
let (kind, axis, label) = if vertical {
(ConstraintKind::Vertical, "Vertical", "Vertical constraint")
} else {
(ConstraintKind::Horizontal, "Horizontal", "Horizontal constraint")
};
let new_command = || {
if vertical {
HorizontalConstraintCommand::vertical()
} else {
HorizontalConstraintCommand::new()
}
};
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
let sel = SelectObjectsCommand::new(cmd);
self.command_line.push_info(&sel.prompt());
self.tabs[i].active_cmd = Some(Box::new(sel));
let command = new_command();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
} else if handles.len() != 1 {
self.command_line
.push_output("Select exactly one entity, then run this constraint again.");
.push_error(&format!("{axis}: select exactly one compatible object."));
} else {
use crate::command::CmdResult;
use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef};
let (kind, label) = match cmd {
"HCONSTRAINT" => (ConstraintKind::Horizontal, "Horizontal constraint"),
"VCONSTRAINT" => (ConstraintKind::Vertical, "Vertical constraint"),
_ => (ConstraintKind::Fixed, "Fixed constraint"),
};
return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint {
kind,
refs: vec![ParametricRef::whole(handles[0])],
driving_param: None,
label,
}));
let handle = handles[0];
let reference = self.tabs[i]
.scene
.document
.get_entity(handle)
.and_then(|entity| {
HorizontalConstraintCommand::preselected_reference(entity, handle)
});
if let Some(reference) = reference {
let plane = self.tabs[i].ucs_xform().working_plane();
let direction = if vertical { plane.y } else { plane.x };
return Some(self.apply_cmd_result(CmdResult::AddHorizontalConstraint {
kind,
selection: HorizontalConstraintSelection::Reference(reference),
direction: acadrust::types::Vector3::new(
direction.x,
direction.y,
direction.z,
),
label,
}));
}
self.tabs[i].scene.deselect_all();
self.command_line.push_error(&format!(
"Invalid selection for {axis}. Select a line segment, polyline segment, text, MText, major or minor axis of ellipse or elliptical arc."
));
let command = new_command();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
}
"FXCONSTRAINT" | "GCFIX" => {
use crate::command::CmdResult;
use crate::modules::parametric::FixConstraintCommand;
use crate::scene::parametric_constraints::ConstraintKind;
// One preselected whole curve applies at once (ribbon
// "select first, then click"); anything else goes through
// the reference's own point-or-object prompt.
let handles = self.tabs[i].scene.selected_handles_in_order();
if let [handle] = handles.as_slice() {
let reference = self.tabs[i]
.scene
.document
.get_entity(*handle)
.and_then(|entity| {
FixConstraintCommand::preselected_reference(entity, *handle)
});
if let Some(reference) = reference {
return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint {
kind: ConstraintKind::Fixed,
refs: vec![reference],
driving_param: None,
label: "Fixed constraint",
}));
}
}
if !handles.is_empty() {
self.tabs[i].scene.deselect_all();
}
let command = FixConstraintCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"CCONSTRAINT" | "GCCOINCIDENT" => {
use crate::modules::parametric::CoincidentConstraintCommand;
let new_cmd = CoincidentConstraintCommand::new();
@@ -1352,8 +1493,93 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
"PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT"
| "NRCONSTRAINT" => {
"QCONSTRAINT" | "GCPERPENDICULAR" => {
use crate::command::CmdResult;
use crate::modules::parametric::PerpendicularConstraintCommand;
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
let command = PerpendicularConstraintCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
} else if handles.len() != 2 {
self.command_line
.push_output("Select exactly two perpendicular-compatible objects.");
} else {
let picks = handles
.iter()
.filter_map(|handle| {
let entity = self.tabs[i].scene.document.get_entity(*handle)?;
PerpendicularConstraintCommand::preselected_reference(entity, *handle)
})
.collect::<Vec<_>>();
if picks.len() == 2 && picks[0].reference != picks[1].reference {
return Some(self.apply_cmd_result(CmdResult::AddPerpendicularConstraint {
first: picks[0].reference,
second: picks[1].reference,
first_fixed: picks[0].fixed_reference,
second_start: picks[1].start_reference,
label: "Perpendicular constraint",
}));
}
self.tabs[i].scene.deselect_all();
self.command_line.push_error(
"Invalid selection for Perpendicular. Select a line segment, polyline segment, text, MText, major or minor axis of ellipse or elliptical arc.",
);
let command = PerpendicularConstraintCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
}
"TCONSTRAINT" => {
use crate::command::CmdResult;
use crate::modules::parametric::TangentConstraintCommand;
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
let command = TangentConstraintCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
} else if handles.len() != 2 {
self.command_line
.push_output("Select exactly two tangent-compatible objects.");
} else {
let refs = handles
.iter()
.filter_map(|handle| {
let entity = self.tabs[i].scene.document.get_entity(*handle)?;
TangentConstraintCommand::preselected_reference(entity, *handle)
})
.collect::<Vec<_>>();
if refs.len() == 2 && refs[0] != refs[1] {
return Some(self.apply_cmd_result(CmdResult::AddTangentConstraint {
first: refs[0],
second: refs[1],
label: "Tangent constraint",
}));
}
self.tabs[i].scene.deselect_all();
self.command_line.push_error(
"Invalid selection for Tangent. Select a line, polyline segment, circle, arc or ellipse.",
);
let command = TangentConstraintCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
}
"ECONSTRAINT" | "GCEQUAL" => {
use crate::modules::parametric::EqualConstraintCommand;
// Both objects are picked inside the command, as in the
// reference; a selection made beforehand is not used.
self.tabs[i].scene.deselect_all();
let command = EqualConstraintCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"PCONSTRAINT" | "LCONSTRAINT" | "NRCONSTRAINT" => {
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
@@ -1369,13 +1595,8 @@ impl OpenCADStudio {
use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef};
let (kind, label) = match cmd {
"PCONSTRAINT" => (ConstraintKind::Parallel, "Parallel constraint"),
"QCONSTRAINT" => {
(ConstraintKind::Perpendicular, "Perpendicular constraint")
}
"TCONSTRAINT" => (ConstraintKind::Tangent, "Tangent constraint"),
"LCONSTRAINT" => (ConstraintKind::Colinear, "Colinear constraint"),
"NRCONSTRAINT" => (ConstraintKind::Normal, "Normal constraint"),
_ => (ConstraintKind::Equal, "Equal constraint"),
_ => (ConstraintKind::Normal, "Normal constraint"),
};
return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint {
kind,
@@ -1389,56 +1610,81 @@ impl OpenCADStudio {
}
}
"NCONSTRAINT" => {
"GCCONCENTRIC" => {
use crate::command::CmdResult;
use crate::modules::parametric::ConcentricConstraintCommand;
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
let sel = SelectObjectsCommand::new(cmd);
self.command_line.push_info(&sel.prompt());
self.tabs[i].active_cmd = Some(Box::new(sel));
} else if handles.len() != 2 {
self.command_line.push_output(
"Select exactly two circles/arcs, then run this constraint again.",
);
let command = ConcentricConstraintCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
} else {
use crate::command::CmdResult;
use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef};
return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint {
kind: ConstraintKind::Concentric,
refs: vec![
ParametricRef::center(handles[0]),
ParametricRef::center(handles[1]),
],
driving_param: None,
label: "Concentric constraint",
}));
let refs = handles
.iter()
.filter_map(|handle| {
let entity = self.tabs[i].scene.document.get_entity(*handle)?;
ConcentricConstraintCommand::preselected_reference(entity, *handle)
})
.collect::<Vec<_>>();
if handles.len() == 2 && refs.len() == 2 && refs[0] != refs[1] {
return Some(self.apply_cmd_result(CmdResult::AddConcentricConstraint {
first: refs[0],
second: refs[1],
label: "Concentric constraint",
}));
}
self.tabs[i].scene.deselect_all();
self.command_line.push_error(
"Invalid selection for Concentric. Select a circle, arc, ellipse or polyline arc segment.",
);
let command = refs
.first()
.copied()
.map(ConcentricConstraintCommand::with_first)
.unwrap_or_else(ConcentricConstraintCommand::new);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
}
"SYCONSTRAINT" => {
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
let sel = SelectObjectsCommand::new(cmd);
self.command_line.push_info(&sel.prompt());
self.tabs[i].active_cmd = Some(Box::new(sel));
} else if handles.len() != 3 {
self.command_line.push_output(
"Select exactly three entities (two circles/arcs, then the mirror line), then run this constraint again.",
);
use crate::modules::parametric::SymmetricConstraintCommand;
let command = SymmetricConstraintCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
} else {
use crate::command::CmdResult;
use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef};
return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint {
kind: ConstraintKind::Symmetric,
refs: vec![
ParametricRef::center(handles[0]),
ParametricRef::center(handles[1]),
ParametricRef::whole(handles[2]),
],
driving_param: None,
label: "Symmetric constraint",
}));
use crate::command::{
CmdResult, SymmetricConstraintSelection,
};
use crate::modules::parametric::SymmetricConstraintCommand;
let refs = (handles.len() == 3).then(|| {
let first = self.tabs[i].scene.document.get_entity(handles[0])?;
let second = self.tabs[i].scene.document.get_entity(handles[1])?;
let axis = self.tabs[i].scene.document.get_entity(handles[2])?;
SymmetricConstraintCommand::preselected_refs(
(first, handles[0]),
(second, handles[1]),
(axis, handles[2]),
)
}).flatten();
if let Some([first, second, axis]) = refs {
return Some(self.apply_cmd_result(CmdResult::AddSymmetricConstraint {
selection: SymmetricConstraintSelection::Objects(first, second),
axis,
label: "Symmetric constraint",
}));
}
self.tabs[i].scene.deselect_all();
self.command_line.push_error(
"Symmetric: select two compatible objects followed by a line axis.",
);
let command = SymmetricConstraintCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
}
@@ -2260,3 +2506,22 @@ mod region_tests {
}
}
}
#[cfg(test)]
mod degenerate_constraint_tests {
use crate::app::OpenCADStudio;
/// Retaining a zero-length line's size used to hand the solver a NaN
/// Jacobian, and its SVD fallback never returned.
#[test]
fn equal_constraint_with_a_zero_length_line_returns() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 0,0 "}"#);
app.automation_op(r#"{"op":"run","cmd":"LINE 5,5 20,7 "}"#);
app.automation_op(r#"{"op":"select","type":"Line"}"#);
app.automation_op(r#"{"op":"run","cmd":"ECONSTRAINT"}"#);
let lines = app.automation_op(r#"{"op":"query","type":"Line","detail":"geometry"}"#);
assert_eq!(lines["ok"], true, "{lines}");
}
}
+20 -2
View File
@@ -1302,8 +1302,9 @@ impl OpenCADStudio {
let all_mode = cmd.starts_with("FINDALL");
let rest = cmd.split_once(' ').map(|(_, r)| r.trim()).unwrap_or("");
// Split at " REPLACE " keyword (case-insensitive)
let (search, replacement) = if let Some(pos) = rest.to_uppercase().find(" REPLACE ")
// Split at " REPLACE " keyword (case-insensitive). ASCII case
// mapping keeps byte offsets valid for slicing `rest`.
let (search, replacement) = if let Some(pos) = rest.to_ascii_uppercase().find(" REPLACE ")
{
(&rest[..pos], Some(rest[pos + 9..].trim()))
} else {
@@ -2386,3 +2387,20 @@ mod align_selected_bounds_tests {
assert_eq!(line_start_x(&app, b), 2.0, "b must keep its offset from a, not collapse onto it");
}
}
#[cfg(test)]
mod find_replace_command_tests {
use super::*;
/// Unicode case mapping can change a string's byte length (`ı` uppercases
/// to `I`), so the REPLACE keyword split must not slice with offsets
/// taken from a case-mapped copy.
#[test]
fn find_replace_accepts_search_text_whose_case_mapping_changes_length() {
for cmd in ["FIND ı REPLACE x", "FINDALL fi REPLACE fi", "FIND ʼn replace n"] {
let mut app = OpenCADStudio::new_for_test();
let i = app.active_tab;
let _ = app.dispatch_inquiry(cmd, i);
}
}
}
+263
View File
@@ -314,6 +314,135 @@ impl OpenCADStudio {
self.command_line
.push_output(crate::t!("UCS aligned to the current view.").as_ref());
}
// UCS FACE <handle> <x,y,z> — adopt a solid's planar face
// as the drawing plane. This is the move Fusion calls
// "create sketch on a face": pick the face, draw flat on
// it, extrude. The pick comes from a surface pick, so it
// already lies on the face and serves as the origin.
"FACE" | "F" => {
let target = parts
.get(2)
.and_then(|token| {
u64::from_str_radix(token.trim().trim_start_matches("0x"), 16).ok()
})
.map(acadrust::Handle::new)
.zip(
parts
.get(3)
.and_then(|value| super::super::helpers::parse_coord(value))
.map(|(point, _)| point),
);
match target {
// Bare `UCS FACE` starts the pick. The click comes
// back through this same arm with a handle and a
// surface point, so both entry points share the
// plane construction below.
None => {
let picker = crate::command::UcsPickCommand { face: true };
self.command_line.push_info(&picker.prompt());
self.tabs[i].active_cmd = Some(Box::new(picker));
}
Some((handle, pick)) => {
let built = self.tabs[i]
.scene
.solid_models
.get(&handle)
.and_then(|body| {
use crate::scene::model::solid_model;
let face =
solid_model::nearest_planar_face(body, pick.to_array())?;
solid_model::planar_face_normal(body, face)
})
.and_then(|normal| {
super::super::helpers::ucs_from_normal(
pick,
glam::DVec3::from_array(normal),
)
});
match built {
Some(ucs) => {
self.tabs[i].active_ucs = Some(ucs);
active_changed = true;
self.command_line.push_output(
crate::t!("UCS aligned to the selected face.").as_ref(),
);
}
None => self.command_line.push_error(
crate::t!("UCS FACE: no planar face at that point.")
.as_ref(),
),
}
}
}
}
// UCS OBJECT <handle> — adopt the plane of a planar
// entity. Reuses the profile extraction EXTRUDE already
// runs, so anything extrudable can also be sketched on.
"OBJECT" | "OB" => {
let target = parts
.get(2)
.and_then(|token| {
u64::from_str_radix(token.trim().trim_start_matches("0x"), 16).ok()
})
.map(acadrust::Handle::new);
match target {
// Bare `UCS OBJECT` starts the pick; the click
// comes back through this arm with the handle.
None => {
let picker = crate::command::UcsPickCommand { face: false };
self.command_line.push_info(&picker.prompt());
self.tabs[i].active_cmd = Some(Box::new(picker));
}
Some(handle) => {
let built = self.tabs[i]
.scene
.document
.get_entity(handle)
.and_then(crate::scene::model::sweep_model::extrusion_profile_of)
.and_then(|(profile, _)| {
// The profile plane already carries the
// entity's own in-plane axes, so adopt
// them rather than re-deriving X from
// the normal: a sketch started on a
// circle then lines up with how that
// circle is stored, not with an
// arbitrary reference direction.
let plane = profile.plane;
let x = glam::DVec3::from_array(plane.x_axis);
let y = glam::DVec3::from_array(plane.y_axis);
if x.length_squared() < 1e-12
|| y.length_squared() < 1e-12
{
return None;
}
let mut ucs = Ucs::new("*ACTIVE*");
ucs.origin = Vector3::new(
plane.origin[0],
plane.origin[1],
plane.origin[2],
);
ucs.x_axis =
Vector3::new(x.x, x.y, x.z);
ucs.y_axis =
Vector3::new(y.x, y.y, y.z);
Some(ucs)
});
match built {
Some(ucs) => {
self.tabs[i].active_ucs = Some(ucs);
active_changed = true;
self.command_line.push_output(
crate::t!("UCS aligned to the selected object.")
.as_ref(),
);
}
None => self.command_line.push_error(
crate::t!("UCS OBJECT: entity defines no plane.").as_ref(),
),
}
}
}
}
"3POINTW" => {
let raw = parts.get(2).copied().unwrap_or("");
let points: Vec<glam::DVec3> = raw
@@ -506,6 +635,72 @@ impl OpenCADStudio {
// PLAN — plan view of a UCS (#326). Shows its options right on
// activation instead of flipping the view like a cube click; the
// view only changes once an option (or the Current default) runs.
// ── Sketch mode ──────────────────────────────────────────────
// Fusion's sketch loop, assembled from pieces that already exist:
// a UCS for the plane, PLAN for the square-on view, the snapper
// for the drafting aids. A sketch is a mode, not a document
// object, so what it produces is ordinary model-space geometry
// and the file stays a DWG every other program can still open.
cmd if cmd == "CREATESKETCH" || cmd.starts_with("CREATESKETCH ") => {
if self.tabs[i].sketch_session.is_some() {
self.command_line.push_error(
crate::t!("A sketch is already open — FINISHSKETCH closes it.")
.as_ref(),
);
return Some(Task::none());
}
let arg = cmd["CREATESKETCH".len()..].trim().to_uppercase();
// The three origin planes. Without them the first sketch in
// an empty drawing would have no face to land on.
let origin_plane = match arg.as_str() {
"XY" => Some(glam::DVec3::Z),
"XZ" => Some(glam::DVec3::NEG_Y),
"YZ" => Some(glam::DVec3::X),
_ => None,
};
if let Some(normal) = origin_plane {
self.tabs[i].active_ucs =
super::super::helpers::ucs_from_normal(glam::DVec3::ZERO, normal);
self.commit_active_ucs_change(i, "CREATESKETCH");
} else if !arg.is_empty() {
self.command_line.push_error(
crate::t!(
"Usage: CREATESKETCH [XY|XZ|YZ]; bare uses the current UCS."
)
.as_ref(),
);
return Some(Task::none());
}
self.open_sketch(i);
}
// BROWSER — toggle the docked outline of planes, sketch and bodies.
"BROWSER" => {
self.show_browser ^= true;
if self.show_browser {
// Dock it on first use rather than shipping it in the
// default layout: an existing user's edges should not
// rearrange themselves on upgrade for a panel they have
// not asked for.
let id = crate::ui::dock::PanelId::Browser;
if self.dock.location(id).is_none() {
// dock() clamps an out-of-range index to the end of
// the edge, so this appends below whatever is there.
self.dock
.dock(id, crate::app::config::DockSide::Right, usize::MAX);
}
// Open expanded so it is usable straight away; the pin
// button still collapses it.
self.dock_expanded = Some(id);
self.command_line
.push_output(crate::t!("Browser opened.").as_ref());
} else {
self.command_line
.push_output(crate::t!("Browser closed.").as_ref());
}
}
"FINISHSKETCH" => {
self.finish_sketch(i);
}
"PLAN" => {
use crate::command::KeywordCommand;
let c = KeywordCommand::new(
@@ -1366,6 +1561,74 @@ impl OpenCADStudio {
/// Snap to the plan view of the UCS whose rotation is `r_ucs`, straight —
/// no "already there → flip to the opposite face" cube behaviour (#326).
/// Enter sketch mode on the current UCS.
///
/// Squaring the view up is not cosmetic: drawing on a plane seen nearly
/// edge-on is the main way geometry ends up somewhere the user did not
/// mean, because a few pixels of cursor travel cover a long way in
/// world space.
fn open_sketch(&mut self, i: usize) {
let opened_with: std::collections::HashSet<acadrust::Handle> = self.tabs[i]
.scene
.document
.entities()
.map(|entity| entity.common().handle)
.collect();
self.tabs[i].sketch_count += 1;
let name = format!("Sketch{}", self.tabs[i].sketch_count);
self.tabs[i].sketch_session = Some(crate::app::document::SketchSession {
name: name.clone(),
previous_ucs: self.tabs[i].active_ucs.clone(),
previous_snap_enabled: self.snapper.snap_enabled,
opened_with,
});
let rotation = self.tabs[i].scene.viewcube_ucs_mat();
self.plan_snap(i, rotation);
self.snapper.snap_enabled = true;
self.command_line.push_output(
crate::tf!("{} open. Draw on the plane, then FINISHSKETCH.", name).as_ref(),
);
}
/// Leave sketch mode, restoring what the sketch borrowed.
///
/// Whatever was drawn becomes the "Previous" selection set, so the next
/// EXTRUDE can take it with the Previous keyword instead of making the
/// user reselect a profile they just finished drawing.
fn finish_sketch(&mut self, i: usize) {
let Some(session) = self.tabs[i].sketch_session.take() else {
self.command_line
.push_error(crate::t!("No sketch is open.").as_ref());
return;
};
let created: Vec<acadrust::Handle> = self.tabs[i]
.scene
.document
.entities()
.map(|entity| entity.common().handle)
.filter(|handle| !session.opened_with.contains(handle))
.collect();
self.tabs[i].active_ucs = session.previous_ucs;
self.snapper.snap_enabled = session.previous_snap_enabled;
self.commit_active_ucs_change(i, "FINISHSKETCH");
if created.is_empty() {
self.command_line.push_output(
crate::tf!("{} closed; nothing was drawn.", session.name).as_ref(),
);
} else {
let count = created.len();
self.tabs[i].prev_selection = created;
self.command_line.push_output(
crate::tf!(
"{} closed with {} object(s). EXTRUDE then Previous uses them.",
session.name,
count
)
.as_ref(),
);
}
}
fn plan_snap(&mut self, i: usize, r_ucs: glam::Mat4) {
let eye_dir = r_ucs.transform_vector3(
crate::scene::CubeRegion::Face(crate::scene::pipeline::viewcube::FACE_TOP)
+88 -8
View File
@@ -12,6 +12,7 @@ mod fileops;
mod inquiry;
mod layerprops;
mod layers;
mod plotvars;
mod styleprops;
mod view;
@@ -63,17 +64,37 @@ impl OpenCADStudio {
// routing, so every path below (Start-tab gate, plugins, all dispatch
// families, the Repeat menu) sees the canonical command. Arguments after
// the first space are left untouched. A non-alias passes through as-is.
// The transparent prefix of commercial solutions: `'PAN` / `'ZOOM` run in the middle
// of another command and hand control back to it afterwards.
let (cmd, quoted) = match cmd.trim().strip_prefix('\'') {
Some(rest) => (rest.trim(), true),
None => (cmd, false),
};
let resolved = self.resolve_alias(cmd);
let cmd = resolved.as_deref().unwrap_or(cmd);
if is_spacemouse_command(cmd) {
return self.run_action(cmd);
}
// A drafting aid only flips a flag, so it must not disturb whatever is
// already running: pressing F8 partway through a LINE means "constrain
// the rest of this line", not "abandon it". Everything below tears the
// running command down, so a transparent one skips straight past it.
// (#677)
if is_transparent(cmd) {
return self
.dispatch_families(cmd, i)
.unwrap_or_else(Task::none);
// (#677) A quoted navigation command does the same; an interactive
// one (ZOOM Window) parks the running command and resumes it when the
// zoom ends, as does a ZOOM the zoom prompt itself hands off to.
let transparent = is_transparent(cmd)
|| (quoted && is_transparent_capable(cmd))
|| (self.tabs[i].transparent_resume && cmd.starts_with("ZOOM"));
if transparent {
if zoom_prompts(cmd) && self.tabs[i].active_cmd.is_some() {
self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take();
self.tabs[i].transparent_resume = true;
self.tabs[i].scene.clear_preview_wire();
}
let task = self.dispatch_families(cmd, i).unwrap_or_else(Task::none);
// A one-shot zoom is over already — resume straight away.
self.resume_transparent_parent(i);
return task;
}
// A new command abandons any grip edit and its numeric input.
let had_pending_grip_input = self.grip_pending.take().is_some();
@@ -112,6 +133,9 @@ impl OpenCADStudio {
// template-property override too (#239).
self.restore_add_selected_defaults();
}
// A command parked behind a transparent zoom / MTP goes with it.
self.tabs[i].suspended_cmd = None;
self.tabs[i].transparent_resume = false;
// Starting any command leaves interactive navigation modes (their own
// command arms below re-enable the selected one).
self.tabs[i].pan_mode = false;
@@ -120,6 +144,9 @@ impl OpenCADStudio {
// Reset the last committed point so the first click of the new command
// is not constrained by ortho/polar relative to a previous command's endpoint.
self.last_point = None;
// A new command collects its own points, so the previous command's
// accepted snaps must not leak into it.
self.clear_accepted_snaps();
// Starting a command restarts the right-click cycle, so its first
// right-click acts as Enter rather than opening the context menu.
self.tabs[i]
@@ -237,6 +264,9 @@ impl OpenCADStudio {
if let Some(t) = self.dispatch_layerprops(cmd, i) {
return Some(t);
}
if let Some(t) = self.dispatch_plotvars(cmd, i) {
return Some(t);
}
if let Some(t) = self.dispatch_styleprops(cmd, i) {
return Some(t);
}
@@ -269,10 +299,38 @@ impl OpenCADStudio {
/// point, so F8 during a MOVE both ended the move and made the dragged ghost
/// vanish. Nothing here starts a command, opens a document or reads geometry,
/// so there is nothing for the teardown to protect. (#677)
pub fn is_transparent(cmd: &str) -> bool {
/// Commands that may run transparently (`'PAN`, `'ZOOM …`): the navigation
/// set, which never edits the drawing.
fn is_transparent_capable(cmd: &str) -> bool {
cmd == "PAN" || cmd == "ZOOM" || cmd.starts_with("ZOOM ") || matches!(cmd, "ZW" | "ZE" | "ZA" | "ZP" | "ZI" | "ZO" | "ZD" | "ZEA" | "ZOBJ")
}
/// ZOOM forms that install an interactive prompt (window corners, object
/// pick) and so need the running command parked while they last.
fn zoom_prompts(cmd: &str) -> bool {
matches!(
cmd,
"ORTHO" | "GRID" | "SNAP" | "POLAR" | "OSNAP" | "DSETTINGS"
"ZOOM" | "ZOOM WINDOW" | "ZOOM W" | "ZW" | "ZOOM OBJECT" | "ZOOM O" | "ZOBJ"
)
}
pub fn is_transparent(cmd: &str) -> bool {
is_spacemouse_command(cmd)
|| matches!(
cmd,
"ORTHO" | "GRID" | "SNAP" | "POLAR" | "OSNAP" | "DSETTINGS"
)
}
fn is_spacemouse_command(cmd: &str) -> bool {
matches!(
cmd,
"SPACEMOUSE"
| "SPACEMOUSEPAUSE"
| "SPACEMOUSEPAN"
| "SPACEMOUSEPANZOOM"
| "SPACEMOUSEAUTO"
| "SPACEMOUSE3D"
)
}
@@ -281,7 +339,7 @@ pub fn is_transparent(cmd: &str) -> bool {
/// source of truth: the dispatch gate refuses everything else, and the ribbon
/// dims the tools this rejects.
pub fn start_allowed(cmd: &str) -> bool {
matches!(
is_spacemouse_command(cmd) || matches!(
cmd,
"NEW"
| "OPEN"
@@ -484,6 +542,8 @@ inventory::submit!(crate::command::CommandRegistration {
"MIRRTEXT",
"ZOOMWHEEL",
"ZOOMFACTOR",
"SHORTCUTMENU",
"SHORTCUTMENUDURATION",
"CURSORSIZE",
"PICKBOX",
"CURSORTYPE",
@@ -499,6 +559,16 @@ inventory::submit!(crate::command::CommandRegistration {
"DELOBJ",
"PLINEGEN",
"PSLTSCALE",
// Plot preferences and the current-layout variables (commands/plotvars.rs).
"PLOTOFFSET",
"PAPERUPDATE",
"PLOTROTMODE",
"PLOTTRANSPARENCYOVERRIDE",
"BACKGROUNDPLOT",
"CTAB",
"TILEMODE",
"PSETUPIN",
"-PSETUPIN",
"DISPSILH",
"WORLDVIEW",
"LIMCHECK",
@@ -612,6 +682,7 @@ inventory::submit!(crate::command::CommandRegistration {
"EXPORT",
"EXPORTSTEP",
"EXPORTSTL",
"EXTERNALREFERENCES",
"EXTRIM",
"FILETAB",
"FIND",
@@ -621,6 +692,7 @@ inventory::submit!(crate::command::CommandRegistration {
"IM",
"IMAGE",
"IMAGEATTACH",
"IMAGEEMBED",
"IMPORTOBJ",
"ISOLATEOBJECTS",
"LA",
@@ -654,6 +726,7 @@ inventory::submit!(crate::command::CommandRegistration {
"PERF",
"PERSP",
"PLOT",
"PRINTERS",
"PLOTSTYLE",
"PLOTSTYLEEDITOR",
"PLOTSTYLEPANEL",
@@ -681,6 +754,12 @@ inventory::submit!(crate::command::CommandRegistration {
"SELSIM",
// Draw a new object of the same type as the selected one. (#239)
"ADDSELECTED",
// Outline of origin planes, open sketch and solid bodies.
"BROWSER",
// Open and close a sketch: a drawing plane, a square-on view and
// snapping, held together until the profile is done.
"CREATESKETCH",
"FINISHSKETCH",
"SHEETSET",
"SHORTCUTS",
"SSM",
@@ -713,6 +792,7 @@ inventory::submit!(crate::command::CommandRegistration {
"XDATA",
"XR",
"XREF",
"-XREF",
"XRELOAD",
"ZOOM",
"ZS",
+259
View File
@@ -0,0 +1,259 @@
//! Plot system variables and the page-setup import command. `PLOTOFFSET`,
//! `PAPERUPDATE`, `PLOTROTMODE`, `PLOTTRANSPARENCYOVERRIDE` and
//! `BACKGROUNDPLOT` are plot preferences of the application, kept with the
//! Plot dialog's persisted settings; `CTAB` and `TILEMODE` say which layout
//! of the drawing is current. Each is typeable on its own (`PLOTOFFSET 1`,
//! `CTAB Layout1`) and through `SETVAR`, and a bare name prompts for the
//! value like the other variables. `PSETUPIN` brings named page setups in
//! from another drawing (see `app::update::page_setup_import`).
use crate::app::{Message, OpenCADStudio};
use iced::Task;
/// The variables this family answers for, for the command registry and the
/// `SETVAR ?` listing.
pub(super) const PLOT_SYSVARS: &[&str] = &[
"PLOTOFFSET",
"PAPERUPDATE",
"PLOTROTMODE",
"PLOTTRANSPARENCYOVERRIDE",
"BACKGROUNDPLOT",
"CTAB",
"TILEMODE",
];
/// The `SETVAR ?` line for this family.
pub(super) fn setvar_listing() -> String {
format!("SETVAR (plot): {}", PLOT_SYSVARS.join(" "))
}
/// What setting a variable did.
enum Outcome {
/// The value was set (or was already that); the variable is part of the
/// application's settings and persists with them.
Preference(String),
/// The value was set and the drawing changed accordingly, possibly with
/// a follow-up task (a layout switch).
Drawing(String, Task<Message>),
/// No value given: the current one, for the prompt.
Current(String),
}
impl OpenCADStudio {
pub(super) fn dispatch_plotvars(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
// PSETUPIN [file [* | name,name…]] — the dash form is the same
// command; both read the file and report on the command line.
for name in ["PSETUPIN", "-PSETUPIN"] {
if cmd == name {
return Some(self.on_psetupin(""));
}
if let Some(args) = cmd.strip_prefix(name).filter(|rest| rest.starts_with(' ')) {
return Some(self.on_psetupin(args));
}
}
let rest = cmd.strip_prefix("SETVAR ").map(str::trim).unwrap_or(cmd);
let mut parts = rest.splitn(2, char::is_whitespace);
let name = parts.next().unwrap_or("").to_ascii_uppercase();
if !PLOT_SYSVARS.contains(&name.as_str()) {
return None;
}
let value = parts.next().map(str::trim).filter(|v| !v.is_empty());
let task = match self.plot_sysvar(i, &name, value) {
Ok(Outcome::Preference(shown)) => {
self.persist_settings_if_changed();
self.command_line.push_output(&format!("{name} = {shown}"));
Task::none()
}
Ok(Outcome::Drawing(shown, task)) => {
self.command_line.push_output(&format!("{name} = {shown}"));
task
}
Ok(Outcome::Current(current)) => {
self.command_line.push_output(
crate::tf!("Enter new value for {name} <{current}>:").as_ref(),
);
self.pending_setvar = Some(name);
Task::none()
}
Err(error) => {
self.command_line.push_error(&error);
Task::none()
}
};
Some(task)
}
fn plot_sysvar(
&mut self,
i: usize,
name: &str,
value: Option<&str>,
) -> Result<Outcome, String> {
let parse_range = |value: &str, max: u8| -> Result<u8, String> {
value
.parse::<u8>()
.ok()
.filter(|v| *v <= max)
.ok_or_else(|| format!("SETVAR: integer from 0 to {max} required."))
};
let d = &mut self.plot_dialog;
match name {
"PLOTOFFSET" => Ok(match value {
Some(v) => {
d.plot_offset_from_edge = parse_range(v, 1)? == 1;
Outcome::Preference((d.plot_offset_from_edge as u8).to_string())
}
None => Outcome::Current((d.plot_offset_from_edge as u8).to_string()),
}),
"PAPERUPDATE" => Ok(match value {
Some(v) => {
d.paper_update = parse_range(v, 1)?;
Outcome::Preference(d.paper_update.to_string())
}
None => Outcome::Current(d.paper_update.to_string()),
}),
"PLOTROTMODE" => Ok(match value {
Some(v) => {
d.plot_rot_mode = parse_range(v, 2)?;
Outcome::Preference(d.plot_rot_mode.to_string())
}
None => Outcome::Current(d.plot_rot_mode.to_string()),
}),
"PLOTTRANSPARENCYOVERRIDE" => Ok(match value {
Some(v) => {
d.transparency_override = parse_range(v, 2)?;
Outcome::Preference(d.transparency_override.to_string())
}
None => Outcome::Current(d.transparency_override.to_string()),
}),
// Bit 1: single plots run in the background; bit 2: batch plots.
"BACKGROUNDPLOT" => {
let current = d.background as u8 | (d.background_publish as u8) << 1;
Ok(match value {
Some(v) => {
let bits = parse_range(v, 3)?;
d.background = bits & 1 != 0;
d.background_publish = bits & 2 != 0;
Outcome::Preference(bits.to_string())
}
None => Outcome::Current(current.to_string()),
})
}
"CTAB" => {
let current = self.tabs[i].scene.current_layout.clone();
let Some(v) = value else {
return Ok(Outcome::Current(current));
};
let Some(target) = self.tabs[i]
.scene
.layout_names()
.into_iter()
.find(|layout| layout.eq_ignore_ascii_case(v))
else {
return Err(format!("CTAB: no layout named \"{v}\"."));
};
let task = if target == current {
Task::none()
} else {
self.on_layout_switch(target.clone())
};
Ok(Outcome::Drawing(target, task))
}
// 1: model space; 0: the paper layout that was last current (the
// drawing's own `CTAB`), else the first one.
"TILEMODE" => {
let in_model = self.tabs[i].scene.current_layout == "Model";
let Some(v) = value else {
return Ok(Outcome::Current((in_model as u8).to_string()));
};
let model = parse_range(v, 1)? == 1;
if model == in_model {
return Ok(Outcome::Drawing((model as u8).to_string(), Task::none()));
}
let target = if model {
"Model".to_string()
} else {
let scene = &self.tabs[i].scene;
let layouts = scene.layout_names();
crate::io::saved_active_layout(&scene.document)
.filter(|saved| saved != "Model" && layouts.contains(saved))
.or_else(|| layouts.into_iter().find(|layout| layout != "Model"))
.ok_or_else(|| "TILEMODE: the drawing has no paper layout.".to_string())?
};
let task = self.on_layout_switch(target);
Ok(Outcome::Drawing((model as u8).to_string(), task))
}
_ => Err(format!("SETVAR: unknown variable \"{name}\".")),
}
}
}
#[cfg(test)]
mod tests {
use crate::app::OpenCADStudio;
fn app() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app
}
fn last_line(app: &OpenCADStudio) -> String {
app.command_line.history.last().map(|line| line.text.clone()).unwrap_or_default()
}
#[test]
fn plot_preferences_are_typeable_directly_and_through_setvar() {
let mut app = app();
let _ = app.run_command_line("PLOTOFFSET 1");
assert!(app.plot_dialog.plot_offset_from_edge);
assert_eq!(last_line(&app), "PLOTOFFSET = 1");
let _ = app.run_command_line("SETVAR PAPERUPDATE 1");
assert_eq!(app.plot_dialog.paper_update, 1);
let _ = app.run_command_line("PLOTROTMODE 0");
assert_eq!(app.plot_dialog.plot_rot_mode, 0);
let _ = app.run_command_line("PLOTTRANSPARENCYOVERRIDE 2");
assert_eq!(app.plot_dialog.transparency_override, 2);
// Out-of-range values are refused and leave the setting alone.
let _ = app.run_command_line("PLOTROTMODE 3");
assert_eq!(app.plot_dialog.plot_rot_mode, 0);
assert!(last_line(&app).contains("0 to 2"));
// The preferences reach the persisted plot config.
let config = app.current_config();
assert!(config.plot.plot_offset_from_edge);
assert_eq!(config.plot.transparency_override, 2);
}
#[test]
fn backgroundplot_maps_to_the_two_background_switches() {
let mut app = app();
let _ = app.run_command_line("BACKGROUNDPLOT 2");
assert!(!app.plot_dialog.background);
assert!(app.plot_dialog.background_publish);
let _ = app.run_command_line("BACKGROUNDPLOT 3");
assert!(app.plot_dialog.background && app.plot_dialog.background_publish);
let _ = app.run_command_line("BACKGROUNDPLOT");
assert_eq!(app.pending_setvar.as_deref(), Some("BACKGROUNDPLOT"));
assert!(last_line(&app).contains("<3>"));
}
#[test]
fn ctab_and_tilemode_switch_the_current_layout() {
let mut app = app();
assert_eq!(app.tabs[app.active_tab].scene.current_layout, "Model");
let _ = app.run_command_line("CTAB layout1");
assert_eq!(app.tabs[app.active_tab].scene.current_layout, "Layout1");
assert_eq!(last_line(&app), "CTAB = Layout1");
let _ = app.run_command_line("TILEMODE 1");
assert_eq!(app.tabs[app.active_tab].scene.current_layout, "Model");
// Back to paper space: the layout that was current last.
let _ = app.run_command_line("SETVAR TILEMODE 0");
assert_eq!(app.tabs[app.active_tab].scene.current_layout, "Layout1");
let _ = app.run_command_line("CTAB Nowhere");
assert!(last_line(&app).contains("no layout named"));
assert_eq!(app.tabs[app.active_tab].scene.current_layout, "Layout1");
let _ = app.run_command_line("CTAB");
assert!(last_line(&app).contains("<Layout1>"));
assert_eq!(app.pending_setvar.as_deref(), Some("CTAB"));
}
}
+63 -3
View File
@@ -956,6 +956,8 @@ impl OpenCADStudio {
"MIRRTEXT"
| "ZOOMWHEEL"
| "ZOOMFACTOR"
| "SHORTCUTMENU"
| "SHORTCUTMENUDURATION"
| "CURSORSIZE"
| "PICKBOX"
| "CURSORTYPE"
@@ -1049,6 +1051,7 @@ impl OpenCADStudio {
| "CONSTRAINTSOLVEMODE"
| "CONSTRAINTINFER"
| "CONSTRAINTBARDISPLAY"
| "CONSTRAINTBARMODE"
) =>
{
return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i);
@@ -1070,8 +1073,9 @@ impl OpenCADStudio {
let name = it.next().unwrap_or("").to_uppercase();
let value = it.next().map(|s| s.trim().to_string());
if name.is_empty() || name == "?" {
self.command_line.push_info(&super::plotvars::setvar_listing());
self.command_line.push_info(
crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(),
crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(),
);
} else {
if name == "CETRANSPARENCY" {
@@ -1267,15 +1271,23 @@ impl OpenCADStudio {
}
if matches!(
name.as_str(),
"CONSTRAINTSOLVEMODE" | "CONSTRAINTINFER" | "CONSTRAINTBARDISPLAY"
"CONSTRAINTSOLVEMODE"
| "CONSTRAINTINFER"
| "CONSTRAINTBARDISPLAY"
| "CONSTRAINTBARMODE"
) {
let current = match name.as_str() {
"CONSTRAINTSOLVEMODE" => i16::from(self.constraint_solve_mode),
"CONSTRAINTINFER" => i16::from(self.constraint_infer),
"CONSTRAINTBARDISPLAY" => self.constraint_bar_display,
"CONSTRAINTBARMODE" => self.constraint_bar_mode,
_ => unreachable!(),
};
let maximum = if name == "CONSTRAINTBARDISPLAY" { 3 } else { 1 };
let maximum = match name.as_str() {
"CONSTRAINTBARDISPLAY" => 3,
"CONSTRAINTBARMODE" => 4095,
_ => 1,
};
if let Some(value) = &value {
match value
.parse::<i16>()
@@ -1291,6 +1303,7 @@ impl OpenCADStudio {
"CONSTRAINTBARDISPLAY" => {
self.constraint_bar_display = mode
}
"CONSTRAINTBARMODE" => self.constraint_bar_mode = mode,
_ => unreachable!(),
}
self.persist_settings_if_changed();
@@ -1490,6 +1503,51 @@ impl OpenCADStudio {
Ok((format!("ZOOMFACTOR = {}", self.zoom_factor), false))
}
},
// Bit code of commercial solutions: 0 = no shortcut menus (right-click
// is Enter), 16 = time-sensitive; everything else is the
// regular shortcut menu. Reported as the defaults of commercial solutions
// (11 = default+edit+command menus, +16 when
// time-sensitive).
"SHORTCUTMENU" => {
use crate::app::settings::RightClickMode;
let code = |mode: RightClickMode| match mode {
RightClickMode::EnterFirst => 0,
RightClickMode::ShortcutMenu => 11,
RightClickMode::TimeSensitive => 27,
};
match &value {
Some(v) => match v.parse::<i32>() {
Ok(bits) if (0..=31).contains(&bits) => {
self.right_click_mode = if bits == 0 {
RightClickMode::EnterFirst
} else if bits & 16 != 0 {
RightClickMode::TimeSensitive
} else {
RightClickMode::ShortcutMenu
};
Ok((format!("SHORTCUTMENU = {}", code(self.right_click_mode)), true))
}
_ => Err("SETVAR: integer from 0 to 31 required.".into()),
},
None => Ok((
format!("SHORTCUTMENU = {}", code(self.right_click_mode)),
false,
)),
}
}
"SHORTCUTMENUDURATION" => match &value {
Some(v) => match v.parse::<i32>() {
Ok(ms) if (100..=1000).contains(&ms) => {
self.right_click_hold_ms = ms;
Ok((format!("SHORTCUTMENUDURATION = {ms}"), true))
}
_ => Err("SETVAR: integer from 100 to 1000 required.".into()),
},
None => Ok((
format!("SHORTCUTMENUDURATION = {}", self.right_click_hold_ms),
false,
)),
},
"CURSORSIZE" => match &value {
Some(v) => match v.parse::<i32>() {
Ok(size) if (1..=100).contains(&size) => {
@@ -2347,6 +2405,8 @@ impl OpenCADStudio {
name.as_str(),
"ZOOMWHEEL"
| "ZOOMFACTOR"
| "SHORTCUTMENU"
| "SHORTCUTMENUDURATION"
| "CURSORSIZE"
| "PICKBOX"
| "CURSORTYPE"